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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 18 additions & 8 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
72 changes: 62 additions & 10 deletions src/core/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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<TransformModel*>(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<TransformModel*>(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
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
{
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/engine/magnifier_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading