Skip to content

Commit cd94de0

Browse files
authored
fix(linux/macos): cierra el popup de detalle sin depender del mouseleave del DOM (egdev6#154) (egdev6#156)
El cierre del popup colgaba exclusivamente de eventos DOM de ratón en dos ventanas Webview transparentes; en macOS (ventanas no activantes) WKWebView puede dejar de emitirlos y el popup queda clavado, incluso moviendo el widget. - Nuevo módulo puro src/lib/detail-presence.ts: tracker de presencia del cursor (widget/detalle) con semántica de estado final testeable (12 tests). - Widget: mientras el popup está abierto, sondeo (400 ms) de cursorPosition() global contra los bounds nativos de ambas ventanas; fuera de ambas → cierre por timer directo que no consulta el detailInsideRef DOM (el flag que se queda atascado); re-entrada → cancela. El muestreo degrada en silencio (Wayland), nunca adivina. - Sin cambios en Rust ni en otros flujos: el mouseleave DOM sigue funcionando como camino primario en Windows; el sondeo es la garantía multiplataforma.
1 parent 1d1b4ac commit cd94de0

3 files changed

Lines changed: 279 additions & 1 deletion

File tree

‎src/Widget.tsx‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
2-
import { availableMonitors, currentMonitor, getCurrentWindow, primaryMonitor, type Monitor } from '@tauri-apps/api/window';
2+
import { availableMonitors, currentMonitor, cursorPosition, getAllWindows, getCurrentWindow, primaryMonitor, type Monitor } from '@tauri-apps/api/window';
33
import { PhysicalPosition } from '@tauri-apps/api/dpi';
44
import { emitTo, listen } from '@tauri-apps/api/event';
55
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
@@ -17,6 +17,7 @@ import { createSessionMonitor } from './lib/session-monitor';
1717
import { needsRefresh } from './lib/widget-refresh';
1818
import { createWidgetRecovery } from './lib/widget-recovery';
1919
import { createLatestQueue, physicalPanelBounds, samePhysical, DETAIL_EVENTS, DETAIL_WINDOW, isCurrentDetailInteraction, panelWidthFor, type DetailInteraction, type DetailState, type PhysicalBounds } from './lib/detail-panel';
20+
import { createDetailPresence, presenceFromPoint } from './lib/detail-presence';
2021
import { ProviderGauge } from './components/ProviderGauge';
2122
import { accountTags } from './lib/accounts';
2223
import { clipSurfaceSafely, measureSurfaceClip } from './lib/surface-clip';
@@ -178,6 +179,24 @@ export function Widget() {
178179
const refreshAbort = useRef<AbortController | null>(null);
179180
const closeTimer = useRef<number | undefined>(undefined);
180181
const openTimer = useRef<number | undefined>(undefined);
182+
/**
183+
* Native cursor presence (#154): on macOS the DOM can stop delivering
184+
* `mouseleave` in transparent non-activating windows, pinning the popup.
185+
* While the popup is open we sample the global cursor position against the
186+
* native bounds of both windows — platform-independent close guarantee.
187+
*/
188+
const presenceRef = useRef(
189+
createDetailPresence({
190+
isOpen: () => detailStateRef.current != null,
191+
scheduleClose: () => {
192+
// Direct close timer: never gated by the DOM-only `detailInsideRef`,
193+
// which is exactly what gets stuck on macOS.
194+
window.clearTimeout(closeTimer.current);
195+
closeTimer.current = window.setTimeout(() => setHovered(null), CLOSE_DELAY);
196+
},
197+
cancelClose: () => window.clearTimeout(closeTimer.current),
198+
}),
199+
).current;
181200
const shown = useRef(false);
182201
const drag = useRef<{ sx: number; sy: number; start: PhysicalPosition | null; sf: number; raf: number; pointerId: number; ending: boolean } | null>(null);
183202
const orbDrag = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
@@ -683,6 +702,48 @@ export function Widget() {
683702
if (detailInsideRef.current) return;
684703
closeTimer.current = window.setTimeout(() => setHovered(null), CLOSE_DELAY);
685704
};
705+
706+
// Muestreo nativo de presencia (#154): mientras el popup esté abierto, la
707+
// posición global del cursor frente a los bounds nativos de ambas ventanas
708+
// alimenta el tracker. Falla en silencio (Wayland puede no exponer la
709+
// posición): el muestreo degrada, nunca adivina.
710+
useEffect(() => {
711+
if (!hovered) {
712+
presenceRef.reset();
713+
return;
714+
}
715+
let disposed = false;
716+
const boundsOf = async (label: string): Promise<PhysicalBounds | null> => {
717+
try {
718+
const win = (await getAllWindows()).find((w) => w.label === label);
719+
if (!win) return null;
720+
const [pos, size] = await Promise.all([win.outerPosition(), win.outerSize()]);
721+
return { x: pos.x, y: pos.y, w: size.width, h: size.height };
722+
} catch {
723+
return null;
724+
}
725+
};
726+
const sample = async () => {
727+
try {
728+
const point = await cursorPosition();
729+
const [widget, detail] = await Promise.all([boundsOf('widget'), boundsOf(DETAIL_WINDOW)]);
730+
if (disposed) return;
731+
const { widget: inWidget, detail: inDetail } = presenceFromPoint(point, widget, detail);
732+
// Sincroniza el flag DOM-only por si su `mouseleave` nunca llegó.
733+
detailInsideRef.current = inDetail;
734+
presenceRef.cursor('widget', inWidget);
735+
presenceRef.cursor('detail', inDetail);
736+
} catch {
737+
// Sin posición de cursor (p. ej. Wayland): no hay conclusión.
738+
}
739+
};
740+
const interval = window.setInterval(() => { void sample(); }, 400);
741+
void sample();
742+
return () => {
743+
disposed = true;
744+
window.clearInterval(interval);
745+
};
746+
}, [hovered, presenceRef]);
686747
useEffect(() => {
687748
dragMounted.current = true;
688749
return () => {

‎src/lib/detail-presence.test.ts‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { createDetailPresence, pointInBounds, presenceFromPoint } from './detail-presence';
3+
4+
/**
5+
* Deterministic tracker whose close bookkeeping records the ORDER of
6+
* schedule/cancel calls. The guarantee under test is about the final state:
7+
* after any transition sequence, a pending close exists iff the cursor is
8+
* outside both surfaces while the popup is open.
9+
*/
10+
function setup(isOpen: () => boolean = () => true) {
11+
vi.useFakeTimers();
12+
const calls: ('schedule' | 'cancel')[] = [];
13+
const scheduleClose = vi.fn(() => calls.push('schedule'));
14+
const cancelClose = vi.fn(() => calls.push('cancel'));
15+
const tracker = createDetailPresence({ isOpen, scheduleClose, cancelClose });
16+
const lastAction = () => calls[calls.length - 1] ?? 'none';
17+
return { tracker, scheduleClose, cancelClose, lastAction };
18+
}
19+
afterEach(() => vi.useRealTimers());
20+
21+
describe('createDetailPresence', () => {
22+
it('escenario #154: popup abierto, el DOM no emite mouseleave y el cursor nativo sale de ambas superficies → queda pendiente de cerrar', () => {
23+
const { tracker, lastAction } = setup();
24+
tracker.cursor('widget', true);
25+
tracker.cursor('widget', false); // sale del widget (tránsito)
26+
tracker.cursor('detail', true); // entra al panel
27+
tracker.cursor('detail', false); // sale del panel: fuera de ambas
28+
expect(lastAction()).toBe('schedule');
29+
});
30+
31+
it('no deja cierre pendiente si el cursor sigue dentro del widget', () => {
32+
const { tracker, lastAction } = setup();
33+
tracker.cursor('widget', true);
34+
tracker.cursor('detail', false);
35+
expect(lastAction()).not.toBe('schedule');
36+
});
37+
38+
it('no deja cierre pendiente si el cursor sigue dentro del panel', () => {
39+
const { tracker, lastAction } = setup();
40+
tracker.cursor('widget', true);
41+
tracker.cursor('widget', false);
42+
tracker.cursor('detail', true);
43+
expect(lastAction()).not.toBe('schedule');
44+
});
45+
46+
it('con el popup cerrado no programa cierre ni toca timers', () => {
47+
const { tracker, scheduleClose, cancelClose, lastAction } = setup(() => false);
48+
tracker.cursor('widget', true);
49+
tracker.cursor('widget', false);
50+
tracker.cursor('detail', true);
51+
tracker.cursor('detail', false);
52+
expect(scheduleClose).not.toHaveBeenCalled();
53+
expect(cancelClose).not.toHaveBeenCalled();
54+
expect(lastAction()).toBe('none');
55+
});
56+
57+
it('la re-entrada dentro del retardo cancela el cierre programado (tránsito widget→panel y de vuelta)', () => {
58+
const { tracker, lastAction } = setup();
59+
tracker.cursor('widget', true);
60+
tracker.cursor('widget', false);
61+
tracker.cursor('detail', true);
62+
expect(lastAction()).toBe('cancel');
63+
tracker.cursor('detail', false);
64+
tracker.cursor('widget', true);
65+
expect(lastAction()).toBe('cancel');
66+
});
67+
68+
it('eventos repetidos idénticos son idempotentes (flujo real widget→panel→fuera)', () => {
69+
const { tracker, scheduleClose, cancelClose, lastAction } = setup();
70+
tracker.cursor('widget', true);
71+
tracker.cursor('widget', true); // duplicado: no-op
72+
tracker.cursor('widget', false); // tránsito al panel
73+
tracker.cursor('widget', false); // duplicado: no-op
74+
tracker.cursor('detail', true);
75+
tracker.cursor('detail', false); // fuera de ambas: cierre pendiente
76+
tracker.cursor('detail', false); // duplicado: no-op
77+
expect(scheduleClose).toHaveBeenCalledTimes(2);
78+
expect(cancelClose).toHaveBeenCalledTimes(2);
79+
expect(lastAction()).toBe('schedule');
80+
// Y una re-entrada posterior cancela: sin cierre pendiente.
81+
tracker.cursor('widget', true);
82+
expect(lastAction()).toBe('cancel');
83+
});
84+
85+
it('isAnyInside refleja la unión de ambas superficies', () => {
86+
const { tracker } = setup();
87+
expect(tracker.isAnyInside()).toBe(false);
88+
tracker.cursor('detail', true);
89+
expect(tracker.isAnyInside()).toBe(true);
90+
tracker.cursor('detail', false);
91+
tracker.cursor('widget', true);
92+
expect(tracker.isAnyInside()).toBe(true);
93+
tracker.cursor('widget', false);
94+
expect(tracker.isAnyInside()).toBe(false);
95+
});
96+
97+
it('reset olvida la presencia (popup cerrado por otra vía)', () => {
98+
const { tracker } = setup();
99+
tracker.cursor('widget', true);
100+
tracker.reset();
101+
expect(tracker.isAnyInside()).toBe(false);
102+
// Tras reset, la re-notificación del mismo valor vuelve a procesarse.
103+
const { tracker: t2, cancelClose } = setup();
104+
t2.cursor('widget', true);
105+
t2.reset();
106+
t2.cursor('widget', true);
107+
expect(cancelClose).toHaveBeenCalledTimes(2);
108+
});
109+
});
110+
111+
describe('pointInBounds', () => {
112+
const b = { x: 100, y: 200, w: 300, h: 150 };
113+
it('contiene puntos interiores y en el borde izquierdo/superior', () => {
114+
expect(pointInBounds({ x: 250, y: 275 }, b)).toBe(true);
115+
expect(pointInBounds({ x: 100, y: 200 }, b)).toBe(true);
116+
});
117+
it('excluye bordes derecho/inferior y puntos exteriores', () => {
118+
expect(pointInBounds({ x: 400, y: 275 }, b)).toBe(false);
119+
expect(pointInBounds({ x: 250, y: 350 }, b)).toBe(false);
120+
expect(pointInBounds({ x: 99, y: 275 }, b)).toBe(false);
121+
expect(pointInBounds({ x: 250, y: 10000 }, b)).toBe(false);
122+
});
123+
});
124+
125+
describe('presenceFromPoint', () => {
126+
const widget = { x: 0, y: 0, w: 100, h: 500 };
127+
const detail = { x: 100, y: 0, w: 330, h: 260 };
128+
it('clasifica el punto por superficie', () => {
129+
expect(presenceFromPoint({ x: 50, y: 250 }, widget, detail)).toEqual({ widget: true, detail: false });
130+
expect(presenceFromPoint({ x: 200, y: 100 }, widget, detail)).toEqual({ widget: false, detail: true });
131+
expect(presenceFromPoint({ x: 1000, y: 1000 }, widget, detail)).toEqual({ widget: false, detail: false });
132+
});
133+
it('con bounds desconocidos la superficie no se puede afirmar', () => {
134+
expect(presenceFromPoint({ x: 50, y: 250 }, widget, null)).toEqual({ widget: true, detail: false });
135+
expect(presenceFromPoint({ x: 50, y: 250 }, null, null)).toEqual({ widget: false, detail: false });
136+
});
137+
});

‎src/lib/detail-presence.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Native cursor presence for the hover detail popup (#154).
3+
*
4+
* The popup's close path must not depend on DOM `mouseleave`: on macOS, a
5+
* Webview in a transparent, non-activating window can stop delivering
6+
* mouse-moved/exit events, and the popup then stays pinned forever. The
7+
* widget forwards tao's reliable window-level `CursorEntered`/`CursorLeft`
8+
* (tao emits them from native tracking rects) for the `widget` and `detail`
9+
* windows as a `detail-cursor-presence` event, and this tracker turns those
10+
* transitions into close scheduling.
11+
*
12+
* DOM `mouseleave` handlers keep working as before (Windows path); the
13+
* native presence is an additional, platform-independent guarantee, so both
14+
* sources converge on the same close timer owned by the widget.
15+
*/
16+
17+
export type PresenceSurface = 'widget' | 'detail';
18+
19+
export interface DetailPresenceHooks {
20+
/** Popup currently open (a detail state is showing). */
21+
isOpen(): boolean;
22+
/** Start (or restart) the close timer. */
23+
scheduleClose(): void;
24+
/** Cancel a pending close timer. */
25+
cancelClose(): void;
26+
}
27+
28+
export interface DetailPresence {
29+
/** Cursor transition for one surface (native or sampled). */
30+
cursor(surface: PresenceSurface, inside: boolean): void;
31+
/** True while the cursor is over either surface. */
32+
isAnyInside(): boolean;
33+
/** Forget presence (popup closed by another path). */
34+
reset(): void;
35+
}
36+
37+
export function createDetailPresence(hooks: DetailPresenceHooks): DetailPresence {
38+
const inside: Record<PresenceSurface, boolean> = { widget: false, detail: false };
39+
40+
return {
41+
cursor(surface, next) {
42+
if (inside[surface] === next) return; // duplicates are no-ops
43+
inside[surface] = next;
44+
if (next) {
45+
// Cursor reached a surface that must keep the popup alive.
46+
if (hooks.isOpen()) hooks.cancelClose();
47+
return;
48+
}
49+
if (hooks.isOpen() && !inside.widget && !inside.detail) hooks.scheduleClose();
50+
},
51+
isAnyInside() {
52+
return inside.widget || inside.detail;
53+
},
54+
reset() {
55+
inside.widget = false;
56+
inside.detail = false;
57+
},
58+
};
59+
}
60+
61+
/** True when a global (physical-pixel) point lies inside native window bounds. */
62+
export function pointInBounds(p: { x: number; y: number }, b: { x: number; y: number; w: number; h: number }): boolean {
63+
return p.x >= b.x && p.x < b.x + b.w && p.y >= b.y && p.y < b.y + b.h;
64+
}
65+
66+
/**
67+
* Native presence of the global cursor over the popup surfaces, from one
68+
* sample. A surface with unknown bounds (`null`: query failed, e.g. Wayland)
69+
* is left untouched by the caller — sampling degrades, it never guesses.
70+
*/
71+
export function presenceFromPoint(
72+
point: { x: number; y: number },
73+
widget: { x: number; y: number; w: number; h: number } | null,
74+
detail: { x: number; y: number; w: number; h: number } | null,
75+
): { widget: boolean; detail: boolean } {
76+
return {
77+
widget: !!widget && pointInBounds(point, widget),
78+
detail: !!detail && pointInBounds(point, detail),
79+
};
80+
}

0 commit comments

Comments
 (0)