diff --git a/CLAUDE.md b/CLAUDE.md index 1f69abed..9aace15b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,10 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. - Tracking never moves the pointer: detached views (`t.viewDetached`); the pointer comes to the view on a mouse move. Never poll the Java Access Bridge at a fixed rate (reads follow bridge events, a window switch or a backed-off retry); load only Authenticode-signed bridge DLLs. -- Shell input panels need nothing special in the transform engine: DWM draws its pointer above them. - Do not bring back pointer freezing or hook-thread view writes. +- Shell input panels (emoji picker, Win+V) need nothing in a Native session: DWM draws its pointer + above them. A Wind session shows the real pointer through the cursor lens while one is open, frozen + by a 1 px clip that Wind moves with the view (#492); keep that freeze hidden from the lock tell. + Never use hook-thread view writes for it. **Input (06)** - Bound keys are swallowed by LL hooks with balanced down/up; release swallowed keys on teardown. diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 67e44c83..708c5d06 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -188,9 +188,9 @@ from the blanker's copies of the standard cursors), placed at the lens point in coordinates so DWM magnifies it with the content. At zoom-in the sprite is stood up on the pointer before the blank (two `DwmFlush` passes) so the hand-off does not blink. The lock rule is the old one: a locked session stays locked with the pointer shown (`LockApplies` gets `nativeSession()`). -Read at zoom-in, so a change applies to the next session. Not ported: the shell-panel pointer -freeze (#283), so the emoji picker and Win+V draw above the sprite; the hook write path, cadence -gates and idle context release, all off or superseded before #369. +Read at zoom-in, so a change applies to the next session. Shell input panels get the real pointer +(below, #492). Not ported: the hook write path, cadence gates and idle context release, all off or +superseded before #369. ## Hiding the real pointer: blanker and crosshair @@ -389,10 +389,20 @@ turns held pan keys into a view delta in screen space, so the feel does not chan ## Shell input panels -The emoji picker, clipboard history and touch keyboard are composed above every window band. The -transform engine needs nothing for them: DWM draws the native pointer above the panels and keeps it -centred, so the hand moves the pointer directly. The earlier workaround for the sprite cursor (a -frozen real pointer moved by Wind after each view write, driven by `TextInputHost` cloak events) -was removed with the sprite. The render engine's pointer sits under the panels, as before. +The emoji picker, clipboard history and touch keyboard are composed above every window band, so +only the real pointer shows over them. A Native session needs nothing: DWM draws its pointer above +the panels and keeps it centred, so the hand moves the pointer directly. + +A Wind session (#283, ported back in #492) switches to the real pointer while a panel is open. +`FocusTracker::shellPanelOpen` follows `TextInputHost`'s `IME` window (UNCLOAKED = open, CLOAKED = +closed, a 250 ms re-check while open, a scan at start-up). `MapPointer` then freezes the pointer with +a 1 px clip and moves it from raw mickeys at the learned desktop gain (Inspect's replay); the model +hides the sprite, restores the cursor set, turns the cursor lens style on (the public prime only if +no lens could be built) and moves the pin right after the view write (`ex.realPointer`), so DWM never +draws one without the other. The freeze is hidden from the lock tell and the gain learner, tracking, +keyboard pan, edge mode and the pan glide pause, and the saved clip goes back on close, Inspect entry +and teardown (only while the clip is still the 1 px pin). Limitation: pens, touch and remote pointers +set absolute positions, which the pin blocks, so they cannot steer over an open panel. +The render engine's pointer sits under the panels, as before. Evidence and the rejected hook-write variant: [../history/SHELL-PANEL-CURSOR-FINDINGS.md](../history/SHELL-PANEL-CURSOR-FINDINGS.md). diff --git a/src/core/main.cpp b/src/core/main.cpp index 1b14cf38..0cdcd8e2 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -302,6 +302,9 @@ struct TickState { wind::ViewOwnerState viewOwner; double viewCx = 0.0, viewCy = 0.0; bool viewDetached = false; // last tick drew a detached frame + bool panelFreeze = false; // #283/#492: a Wind session's real pointer is frozen and moved by Wind + double panelX = 0, panelY = 0; // its desktop position (sub-pixel) + RECT panelSavedClip{}; // the clip to give back when the panel closes wind::KeyPan keyPan; // keyboard panning motion (#287) // Event-driven idle (#71): the loop slept before this tick (clamp the motion dt, keep the raw // one for wall-clock gates), and when the view was last active (the settle window). @@ -1614,9 +1617,23 @@ static void EnterSession(TickState& t, bool zoomed, bool inspect) { { wind::SpanScope span_(wind::kSpanActivate); t.model->onActivate(); } // grab a live frame, not a stale cached one } +// Leave the shell-panel freeze (#283, #492): give the saved clip back. Runs from the in-session +// exit, Inspect entry (before Inspect pins its own 1px clip) AND the active -> idle teardown: a +// quick-zoom snap-out drops the level to 1x in one tick, skipping the in-session branch, which left +// the pointer pinned to one pixel (review #284). The clip is restored only while it is still our +// 1px pin: anything that took the clip since (a game, another tool) wins over a stale snapshot. +static void EndPanelFreeze(TickState& t) { + if (!t.panelFreeze) return; + RECT cur{}; + if (GetClipCursor(&cur) && cur.right - cur.left <= 1 && cur.bottom - cur.top <= 1) + ClipCursor(&t.panelSavedClip); + t.panelFreeze = false; +} + // Inspect enter: freeze the real cursor, seed the look point, and decide game-inspect. // Zeroes this tick's raw motion so the first tick after entry pans by zero. static void EnterInspect(TickState& t, bool zoomed, int& rawDx, int& rawDy) { + EndPanelFreeze(t); // Inspect reads the clip below and pins its own // Freeze the real cursor where it is; the look point (mapper center) starts there. POINT pt; GetCursorPos(&pt); // Read before the freeze clip below replaces the clip (M4, InspectLockTell). @@ -1739,6 +1756,11 @@ static PanDelta ResolvePanDelta(TickState& t, bool inspect, double lvl, double d if (t.cfg.mouseAlign == 1 && t.viewDetached && !clipConfined && wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) rawMag = 0; + // Shell panel freeze (#283, #492): the 1px clip and the frozen pointer are OURS, and they look + // exactly like mouselook. Fed to the tell, it flapped LOCKED/free every ~20 ms, each flip + // leaving and re-entering the panel regime (sprite and real pointer swapped per flip): the + // field flicker. Hide both while the freeze is ours. + if (t.panelFreeze) { clipConfined = false; rawMag = 0; } bool locked = t.detector.update(clipConfined, rawMag, std::abs(curDx) + std::abs(curDy), @@ -1823,7 +1845,8 @@ static PanDelta ResolvePanDelta(TickState& t, bool inspect, double lvl, double d // under-reports output and would teach a too-low gain. // Also skip a pointer pinned against the clip bound: it cannot move, so the output // under-reports exactly like a confined clip (modest downward bias otherwise). - if (!clipConfined && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts && + // Not during the shell-panel freeze: the pointer moves only by Wind then (review #284). + if (!clipConfined && !t.panelFreeze && (std::abs(rawDx) + std::abs(rawDy)) >= wind::GainLearner::kMinCounts && !wind::PointerPinnedAtEdge(cur.x, cur.y, clip.left, clip.top, clip.right, clip.bottom)) { const double dtMs_ = dt * 1000.0; const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); @@ -1913,7 +1936,7 @@ static TickForeground ReadForegroundAndArmWall(TickState& t, double lvl) { // mapper to the real pointer, and the mapper step. The glide may move the pointer (cur follows). struct PointerMap { MapResult r; bool freeCursor, gameCursor; }; static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, bool fsCover, - POINT& cur, int curDx, int curDy, int dx, int dy) { + POINT& cur, int curDx, int curDy, int dx, int dy, int rawDx, int rawDy) { // FREE CURSOR (issue #205) - native Magnifier's model, measured not assumed. // // tools/mag_formula_probe.ps1 + mag_trackmode_probe.ps1 drove the real Magnifier and read @@ -1940,8 +1963,32 @@ static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, // integrates raw deltas instead (issue #3 / #158). const bool freeCursor = !inspect && !t.lockEff && dynamic_cast(t.model) != nullptr; - // (A shell input panel needs nothing special: DWM draws its pointer above the panels and - // keeps it centred, so the hand moves the pointer directly.) + // SHELL INPUT PANEL, Wind session (#283, #492): while the emoji picker (or clipboard history, + // touch keyboard) is open, the real pointer replaces the sprite (the shell composes its panels + // above every window band; only the real pointer is drawn above them). A real pointer the hand + // moves between ticks drifts off the view by speed x tick x level (the wobble, 44 px at 10.7x), + // so it is FROZEN with a 1px clip and moved only by Wind, in the same tick and right next to the + // view write, from raw mickeys at the learned desktop gain (Inspect's replay). Hook-thread + // writes were tried and rejected (#284: hitches). Tracking and edge mode pause meanwhile. + // A native session needs none of this: DWM draws its pointer above the panels and keeps it + // centred, so the hand moves the pointer directly. + const auto* tmPanel = freeCursor ? dynamic_cast(t.model) : nullptr; + const bool panel = tmPanel && !tmPanel->nativeSession() && lvl > 1.001 && g_track.shellPanelOpen(); + if (panel && !t.panelFreeze) { + GetClipCursor(&t.panelSavedClip); + t.panelX = cur.x; t.panelY = cur.y; + t.panelFreeze = true; + } else if (!panel && t.panelFreeze) { + EndPanelFreeze(t); // this rig keeps a work-area clip + } + if (panel) { + const double inC = std::sqrt((double)rawDx * rawDx + (double)rawDy * rawDy); + const double g = t.gainLearner.gainFor(inC, dt * 1000.0); + t.panelX += rawDx * g * t.cfg.cursorSensitivity; + t.panelY += rawDy * g * t.cfg.cursorSensitivity; + t.panelX = (std::min)((std::max)(t.panelX, (double)t.mon.x), (double)(t.mon.x + t.mon.w - 1)); + t.panelY = (std::min)((std::max)(t.panelY, (double)t.mon.y), (double)(t.mon.y + t.mon.h - 1)); + } // PAN GLIDE (issue #430, src/cursor/pan_glide.h): a soft stop, a few screen px at most. The pointer // itself eases on and the view follows by DWM centring. curDx/curDy is this tick's HAND motion: during a // glide the previous tick ended with the baseline on Wind's own step, so any motion here is @@ -1950,7 +1997,7 @@ static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, const bool btn = ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | GetAsyncKeyState(VK_MBUTTON)) & 0x8000) != 0; const bool glideOk = t.cfg.panGlideMaxPx > 0 && freeCursor && lvl > 1.001 && !fsCover && - !t.viewDetached && !btn; + !t.viewDetached && !btn && !t.panelFreeze; 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, wind::PanGlideTauS(t.cfg.panGlideMaxPx), @@ -1987,7 +2034,10 @@ static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, gameCursor ? "on" : "off", gameLagMs, lvl); t.gameCursor = gameCursor; } - if (freeCursor) { + if (panel) { + t.ptrHist.clear(); + t.mapper.reset(t.panelX - t.mon.x, t.panelY - t.mon.y); + } else if (freeCursor) { POINT cp; if (GetCursorPos(&cp)) { LARGE_INTEGER qc; QueryPerformanceCounter(&qc); @@ -2013,7 +2063,7 @@ static void StepTracking(TickState& t, double lvl, bool inspect, bool fsCover, b // fsCover (read once above, see the "Foreground facts for this tick" comment) is the // borderless-fullscreen-game tell; reused here rather than a second ForegroundCoversMonitor // call (it is also what fsGame below aliases). - const bool trackEnabled = lvl > 1.001 && !inspect && !t.lockEff && !fsCover && + const bool trackEnabled = lvl > 1.001 && !inspect && !t.lockEff && !fsCover && !t.panelFreeze && (t.cfg.trackCaret != 0 || t.cfg.trackFocus != 0); { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(trackEnabled, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } if (t.cfg.trackLog) { // #326: why tracking is on or off, logged on every change @@ -2028,7 +2078,7 @@ static void StepTracking(TickState& t, double lvl, bool inspect, bool fsCover, b } // Keyboard panning (#287): only presses the hook swallowed count (a key that went to the app // at 1x never pans after a zoom-in mid-press); without the hook, the polled combo. - const bool panEnabled = panArmed; + const bool panEnabled = panArmed && !t.panelFreeze; // a shell panel freeze owns the view (#492) double panDx = 0, panDy = 0; if (panEnabled) { const bool kb = g_input.kbHookActive(); @@ -2140,7 +2190,7 @@ static void StepTracking(TickState& t, double lvl, bool inspect, bool fsCover, b t.lastSetVirtual = cur; r = wind::DetachedMap(t.viewCx, t.viewCy, px, py, lvl, t.mon.w, t.mon.h); t.viewDetached = edges; // edge mode keeps the view where it is - } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !inspect && !t.lockEff) { + } else if (t.cfg.mouseAlign == 1 && lvl > 1.001 && !inspect && !t.lockEff && !t.panelFreeze) { // MOUSE EDGE MODE (issue #276 phase 2): the pointer roams freely inside the view and // the view moves only when it reaches the margin band, just far enough. No weld: // the pointer is real, so clicks are native. Mouselook (locked) and Inspect keep the @@ -2425,6 +2475,7 @@ static PresentExtras BuildPresentExtras(TickState& t, double lvl, double dt, boo t.zoom.hasTarget(); } if (quiesceHold) ex.suppressCursorSync = true; + ex.realPointer = t.panelFreeze; // #492: the weld moves the frozen real pointer with the view // Native cursor (issue #369): DWM may own the pan only where the view is a pure function of // the pointer. { @@ -2714,6 +2765,7 @@ static void UpdateCursorBaseline(TickState& t, bool inspect, bool doPresent, boo // Session exit (active -> idle): tear the overlay down, restore the cursor and leave Inspect. static void TeardownSession(TickState& t) { + EndPanelFreeze(t); // #283: never leave the pointer pinned (review #284) // The caret/focus watcher is switched off only from the zoomed view block; a zoom-out that // snaps straight to 1.0 skipped it and left the watcher polling at 1x (#71). { wind::SpanScope span_(wind::kSpanTrack); g_track.setActive(false, t.cfg.trackCaret != 0, t.cfg.trackFocus != 0, t.cfg.trackLog != 0); } @@ -2786,7 +2838,7 @@ static void RunActiveTick(TickState& t, double lvl, double dt, bool zoomed, bool const HWND fgTick = tf.fgTick; const bool fsCover = tf.fsCover, fgBorderless = tf.fgBorderless; const bool wallNeeded = tf.wallNeeded, liftIfTransform = tf.liftIfTransform; - const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy); + const PointerMap pm = MapPointer(t, inspect, lvl, dt, fsCover, cur, curDx, curDy, dx, dy, rawDx, rawDy); const bool freeCursor = pm.freeCursor, gameCursor = pm.gameCursor; MapResult r = pm.r; StepTracking(t, lvl, inspect, fsCover, panArmed, kbHook, dt, wallNeeded, cur, curDx, curDy, r); diff --git a/src/engine/magnifier_model.h b/src/engine/magnifier_model.h index cc03b482..ff851170 100644 --- a/src/engine/magnifier_model.h +++ b/src/engine/magnifier_model.h @@ -24,6 +24,11 @@ struct PresentExtras { // centre this tick - the weld would fight the hand and the dragged content flickers between the // two positions. The lens follows the pointer instead (RunTick feeds unscaled deltas). bool suppressCursorSync = false; + // A shell input panel is open in a Wind (sprite) transform session (#283, #492): the shell + // composes the panel above every band, so the sprite would go under it. Show the REAL pointer, + // magnified through the cursor lens, pinned by RunTick's 1 px clip that the model moves with the + // view. Transform model only. + bool realPointer = false; // The view is a pure function of the real pointer this tick (free cursor, mouse-owned, no // walls, no quiesce): the transform model may hand the pan to DWM (issue #369). bool dwmCentre = false; diff --git a/src/track/focus_track.cpp b/src/track/focus_track.cpp index e00f7187..71a9f93b 100644 --- a/src/track/focus_track.cpp +++ b/src/track/focus_track.cpp @@ -10,20 +10,68 @@ #include #include #include +#include #include #include #include #include #include #pragma comment(lib, "oleacc.lib") +#pragma comment(lib, "dwmapi.lib") namespace wind { static const UINT kWakeMsg = WM_APP + 0x61; // an event arrived: resolve after coalescing static const UINT kActiveMsg = WM_APP + 0x62; // active_ changed (wParam = new value): retune (#71) static const UINT_PTR kCoalesceTimer = 1; static FocusTracker* g_self = nullptr; // WinEvent callbacks have no context pointer +static UINT_PTR g_panelTimer = 0; // re-checks a panel marked open (tracker thread only) + +// The shell's input panels (emoji picker, clipboard history, touch keyboard) are hosted by +// TextInputHost.exe and composed by the shell ABOVE every app window, so no band Wind can create +// covers them (#283, measured 2026-09-29). They never change as windows either: the only reliable +// signal is TextInputHost's "IME" window, uncloaked while a panel shows and cloaked when it closes. +// "IME" is a common class name (every GUI thread has one), so the owning process is checked too. +static bool IsShellPanelWindow(HWND h) { + wchar_t cls[16] = {}; + if (!GetClassNameW(h, cls, 16) || wcscmp(cls, L"IME") != 0) return false; + DWORD pid = 0; GetWindowThreadProcessId(h, &pid); + HANDLE p = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!p) return false; + wchar_t path[MAX_PATH]; DWORD n = MAX_PATH; + bool yes = false; + if (QueryFullProcessImageNameW(p, 0, path, &n)) { + const wchar_t* base = wcsrchr(path, L'\\'); + yes = _wcsicmp(base ? base + 1 : path, L"TextInputHost.exe") == 0; + } + CloseHandle(p); + return yes; +} struct FocusTrackImpl { + // Tracker thread only (the cloak hook, the start-up scan and the timer all run there). + static void SetPanel(bool open, HWND h) { + if (!g_self) return; + g_self->panelHwnd_.store(open ? h : nullptr); + if (g_self->panelOpen_.exchange(open) != open) + wind::Log(wind::LogLevel::Info, "track", "shell input panel %s", open ? "open" : "closed"); + // A missed close (TextInputHost restarted, an event dropped) must not leave the real pointer + // on for good: a 250 ms timer re-checks the remembered window while it is marked open. + if (open && !g_panelTimer) g_panelTimer = SetTimer(nullptr, 0, 250, nullptr); + if (!open && g_panelTimer) { KillTimer(nullptr, g_panelTimer); g_panelTimer = 0; } + } + static void CheckPanel() { + if (!g_self || !g_self->panelOpen_.load()) return; + HWND ph = static_cast(g_self->panelHwnd_.load()); + DWORD cloaked = 0; + if (!ph || !IsWindow(ph) || + (SUCCEEDED(DwmGetWindowAttribute(ph, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && cloaked)) + SetPanel(false, nullptr); + } + static void CALLBACK OnCloak(HWINEVENTHOOK, DWORD ev, HWND h, LONG obj, LONG, DWORD, DWORD) { + if (!g_self || !h || obj != OBJID_WINDOW) return; + if (!IsShellPanelWindow(h)) return; + SetPanel(ev == EVENT_OBJECT_UNCLOAKED, h); + } static void CALLBACK OnWinEvent(HWINEVENTHOOK, DWORD ev, HWND, LONG obj, LONG, DWORD, DWORD) { if (!g_self || !g_self->active_.load()) return; if (ev == EVENT_OBJECT_LOCATIONCHANGE && obj != OBJID_CARET) return; @@ -328,6 +376,7 @@ struct FocusRun { // LOCATIONCHANGE fires for every moving window and caret system-wide: installed only while the // tracker is active (zoomed), never at 1x (#71). The 16 ms caret backstop likewise. HWINEVENTHOOK h4 = nullptr; + HWINEVENTHOOK h5 = nullptr; // shell input panels (#283, #492): always on, it fires only on cloaking UINT_PTR pollTimer = 0; void retune(bool on) { if (on && !h4) @@ -692,6 +741,16 @@ void FocusRun::setUp() { h1 = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); h2 = SetWinEventHook(EVENT_SYSTEM_MENUPOPUPSTART, EVENT_SYSTEM_MENUPOPUPSTART, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); h3 = SetWinEventHook(EVENT_OBJECT_FOCUS, EVENT_OBJECT_FOCUS, nullptr, FocusTrackImpl::OnWinEvent, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + h5 = SetWinEventHook(EVENT_OBJECT_CLOAKED, EVENT_OBJECT_UNCLOAKED, nullptr, FocusTrackImpl::OnCloak, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + // A panel already open when we start raised its UNCLOAKED before the hook existed (review #284). + EnumWindows([](HWND h, LPARAM) -> BOOL { + DWORD cloaked = 1; + if (IsShellPanelWindow(h) && SUCCEEDED(DwmGetWindowAttribute(h, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && !cloaked) { + FocusTrackImpl::SetPanel(true, h); + return FALSE; + } + return TRUE; + }, 0); retune(active_.load()); // an activation that raced thread start-up is picked up here } @@ -727,6 +786,8 @@ void FocusRun::messageLoop(MSG& m) { pendingFocus = false; focusRetries = 0; } + } else if (m.message == WM_TIMER && m.wParam == g_panelTimer && g_panelTimer) { + FocusTrackImpl::CheckPanel(); } else if (m.message == WM_TIMER) { if (active_.load() && wantCaret_.load()) resolve(false, true); // backstop poll } @@ -736,7 +797,8 @@ void FocusRun::messageLoop(MSG& m) { void FocusRun::tearDown() { if (pollTimer) KillTimer(nullptr, pollTimer); - for (HWINEVENTHOOK h : { h1, h2, h3, h4 }) if (h) UnhookWinEvent(h); + if (g_panelTimer) { KillTimer(nullptr, g_panelTimer); g_panelTimer = 0; } + for (HWINEVENTHOOK h : { h1, h2, h3, h4, h5 }) if (h) UnhookWinEvent(h); if (uia && fh) uia->RemoveFocusChangedEventHandler(fh); if (fh) fh->Release(); focusLookup.reset(); diff --git a/src/track/focus_track.h b/src/track/focus_track.h index 754a2557..244dd1f9 100644 --- a/src/track/focus_track.h +++ b/src/track/focus_track.h @@ -14,6 +14,9 @@ class FocusTracker { void stop(); void setActive(bool on, bool wantCaret, bool wantFocus, bool log); TrackSnapshot snapshot() const { std::lock_guard g(mu_); return snap_; } + // A shell input panel (emoji picker Win+., clipboard Win+V, touch keyboard) is open (#283, #492). + // Watched always, independent of setActive: a Wind (sprite) session shows the real pointer then. + bool shellPanelOpen() const { return panelOpen_.load(); } private: void run(); friend struct FocusTrackImpl; @@ -23,6 +26,8 @@ class FocusTracker { std::atomic tid_{0}; std::atomic stopRequested_{false}; std::atomic active_{false}, wantCaret_{false}, wantFocus_{false}, log_{false}; + std::atomic panelOpen_{false}; + std::atomic panelHwnd_{nullptr}; mutable std::mutex mu_; TrackSnapshot snap_; unsigned seq_ = 0; diff --git a/src/transform/transform_model.cpp b/src/transform/transform_model.cpp index 03a80ce9..8405885b 100644 --- a/src/transform/transform_model.cpp +++ b/src/transform/transform_model.cpp @@ -65,6 +65,8 @@ static void DeliverOwedNudge() { } void TransformModel::resetTransformState() { nativePrimed_ = false; // a rebuilt context needs its own public prime (#369) + panelPrimed_ = false; // ...and so does a shell panel's real pointer (#283, review #284) + panelLens_ = false; forceWrite_ = false; // lastLevel_ = 0 already forces the next write ixPubLevel_ = 0.0; ladderReq_ = 0.0; ladderOut_ = 0.0; @@ -452,7 +454,9 @@ void TransformModel::setActive(bool active) { const double parkMs = double(pb.QuadPart - pa.QuadPart) * 1000.0 / fr.QuadPart; if (parkMs > 5.0) wind::Log(wind::LogLevel::Info, "transform", "identity park took %.1fms", parkMs); - if (nativeSession_ && host_.cursorLensReady()) { + const bool panelLensWasOn = panelLens_; + endPanelPointer(); // a Wind session that ended with a shell panel open (#492) + if ((nativeSession_ || panelLensWasOn) && host_.cursorLensReady()) { // Native cursor: back to the hardware pointer at 1x (the composed one taxes every cursor // change a game makes). The hardware plane repaints only on the next cursor EVENT, so // nudge the pointer a pixel and back. @@ -930,7 +934,19 @@ void TransformModel::weldPointer(const MapResult& r, const PresentExtras& ex) { // whether SetCursorPos REALLY ran, so RunTick can baseline on the weld point only when it did // (#169 measured-baseline law; assuming it landed is the unstable-servo bug). weldedLastFrame_ = false; - if (!ex.suppressCursorSync) { + if (ex.realPointer) { + // Shell panel freeze (#283, #492): the pointer is pinned by a 1px clip; moving that clip + // moves the pointer, right after the view write, so DWM never draws one without the other. + const int cx = r.clickDesktopX + mon_.x, cy = r.clickDesktopY + mon_.y; + RECT have{}; + GetClipCursor(&have); + if (have.left != cx || have.top != cy || have.right != cx + 1 || have.bottom != cy + 1) { + const RECT pin{ cx, cy, cx + 1, cy + 1 }; + ClipCursor(&pin); + weldedLastFrame_ = true; + } + lastClickX_ = cx; lastClickY_ = cy; haveLastClick_ = true; + } else if (!ex.suppressCursorSync) { int cx = ex.clickOverride ? ex.clickDesktopX : (r.clickDesktopX + mon_.x); int cy = ex.clickOverride ? ex.clickDesktopY : (r.clickDesktopY + mon_.y); if (!haveLastClick_ || cx != lastClickX_ || cy != lastClickY_) { @@ -941,6 +957,15 @@ void TransformModel::weldPointer(const MapResult& r, const PresentExtras& ex) { } } +// A Wind session's shell-panel pointer ends (#492): the lens style goes back off, so the hardware +// pointer returns for the sprite to blank (a native session owns the lens style itself and never +// sets panelLens_). A public prime cannot be undone; forgetting it lets the next panel prime again. +void TransformModel::endPanelPointer() { + if (panelLens_) host_.setCursorLens(false); + panelLens_ = false; + panelPrimed_ = false; +} + void TransformModel::presentCursor(const MapResult& r, double level, const PresentExtras& ex) { if (ex.cursorLocked && ex.drawCursor) { // Inspect mode: the real cursor is frozen at the (overridden) click point, but the thing the @@ -954,7 +979,44 @@ void TransformModel::presentCursor(const MapResult& r, double level, const Prese sprite_->moveTo(r.clickDesktopX + mon_.x, r.clickDesktopY + mon_.y); lastSpriteX_ = INT_MIN; // the legacy sprite's move dedupe must not skip its next move sprite_->keepOnTop(); + } else if (!nativeSession_ && ex.realPointer && ex.drawCursor && level > 1.001) { + // SHELL INPUT PANEL in a Wind session (#283, #492). The emoji picker and its siblings are + // composed by the shell above every window band, so the sprite goes under them; the real + // pointer is the one thing drawn above. The cursor lens style makes DWM draw it magnified + // (the native session's mechanism, 0.2 ms once built; the public write is only the fallback, + // it blocks 200-260 ms). RunTick has frozen the pointer and the weld above moves it with the view. + sprite_->hide(); + lastSpriteX_ = INT_MIN; + bool repaint = false; + // Give the pointer back whichever way it was hidden: setActive() blanks the cursor set at + // zoom-in without cursorHidden_, so a panel open at zoom-in stayed invisible (field, #284). + if (cursorHidden_) { ShowSystemCursor(TRUE); cursorHidden_ = false; } + if (blanker_->blanked()) { + blanker_->restoreSync(); // the clip nudge below needs the restored shape in place + repaint = true; + } + if (!panelLens_ && !panelPrimed_) { + if (host_.createCursorLens() && host_.setCursorLens(true)) { + panelLens_ = true; + } else if (lastLevel_ > 1.0) { + host_.setTransform((float)lastLevel_, lastOffX_, lastOffY_, lastTxX_, lastTxY_, false); + panelPrimed_ = true; + } + wind::Log(wind::LogLevel::Info, "transform", "shell panel: real pointer (%s)", + panelLens_ ? "cursor lens" : "public prime"); + repaint = true; + } + // The pointer repaints (and takes the lens mode) only on the next cursor EVENT, and a + // same-position SetCursorPos is none; it is pinned by its 1px clip, so nudge the clip a + // pixel and back (review #284). The weld above has already placed the pin this frame. + if (repaint && haveLastClick_) { + const RECT off{ lastClickX_ + 1, lastClickY_, lastClickX_ + 2, lastClickY_ + 1 }; + const RECT pin{ lastClickX_, lastClickY_, lastClickX_ + 1, lastClickY_ + 1 }; + ClipCursor(&off); + ClipCursor(&pin); + } } else if (!nativeSession_ && ex.drawCursor && level > 1.001) { + endPanelPointer(); // a panel just closed: the sprite takes over again // LEGACY SPRITE SESSION (txNativeCursor=0, issue #463): the pre-#369 cursor. The real pointer // is hidden (blanker for standard shapes, MagShowSystemCursor for app-custom ones) and its // shape is drawn into the sprite window, placed in DESKTOP coordinates at the lens point: @@ -963,8 +1025,7 @@ void TransformModel::presentCursor(const MapResult& r, double level, const Prese // own pointer (games, video) refreshShape() reports Hidden every tick, and hiding before // checking ran a full blank and restore of every system cursor per tick. No lockstep gate: // the sprite follows the mapper always, or it froze in the edge zones (field 2026-08-26). - // Shell input panels (emoji picker, Win+V) draw above the sprite here: the pre-#369 panel - // pointer freeze (#283) is not part of this fallback. + // A shell input panel takes the real-pointer branch above instead (#492). updateSpriteLayer(); if (sprite_->refreshShape() == CursorSprite::ShapeStatus::Rendered) { if (!cursorHidden_) { @@ -984,6 +1045,7 @@ void TransformModel::presentCursor(const MapResult& r, double level, const Prese if (cursorHidden_) { ShowSystemCursor(TRUE); blanker_->restore(); cursorHidden_ = false; } } } else { + endPanelPointer(); // no-op unless a Wind session's panel pointer was on sprite_->hide(); if (level > 1.001) { // NATIVE CURSOR (issue #369): DWM draws the real pointer into the magnified frame, above @@ -1111,7 +1173,9 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // whose 1px freeze clip must never be disturbed - ex.clickOverride marks it). // Hot like the other knobs: switched off mid-zoom, give the clip back now rather than at the // end of the session (issue #274). Gated so an unused edgeClip costs no per-tick syscall. - if (cfg.edgeClip != 0 || edgeClipActive_) + // (Not while a shell panel froze the pointer: its 1px clip is RunTick's, and restoring the edge + // clip here would free it. RunTick gives the saved clip back when the panel closes.) + if ((cfg.edgeClip != 0 || edgeClipActive_) && !ex.realPointer) edgeClipManage(cfg.edgeClip != 0 && applyLevel > 1.001 && !ex.clickOverride); weldPointer(r, ex); diff --git a/src/transform/transform_model.h b/src/transform/transform_model.h index b72297ab..353c6009 100644 --- a/src/transform/transform_model.h +++ b/src/transform/transform_model.h @@ -132,6 +132,11 @@ class TransformModel : public IMagnifierModel { // draw the real pointer magnified (the fallback when the cursor lens could not be built). // dwmCentreOn_: DWM owns the pan (DWMUpdated TRUE). bool nativePrimed_ = false; + // Shell panel (#492): the lens style is on for a Wind session's real pointer (panelLens_), or + // the public prime was written because no lens could be built (panelPrimed_). + bool panelLens_ = false; + bool panelPrimed_ = false; + void endPanelPointer(); // lens style back off (sprite or teardown) bool dwmCentreOn_ = false; bool dwmCentreBroken_ = false; // the export is missing or refused: never retry, log once bool forceWrite_ = false; // a centring switch owes DWM one real write