diff --git a/app/src/assets/spatial.js b/app/src/assets/spatial.js
index d5f08ed..5bff8da 100644
--- a/app/src/assets/spatial.js
+++ b/app/src/assets/spatial.js
@@ -254,41 +254,91 @@
})();
// The controller's pointer: the left stick moves it and A clicks where it points (real mouse events at the same spot,
-// sent by the shell). It is a ring, not an arrow, so it never passes for the system mouse; the dot in the middle is the
-// spot that gets clicked.
+// sent by the shell). An arrow that turns into a pointing hand over anything clickable, like a mouse; white with a black
+// edge and a shadow so it reads on any page. The tip of the arrow and of the finger is the spot that gets clicked.
(() => {
if (window.__joyCursor) return;
- const SIZE = 34;
- const ring = '';
+ const svg = (box, body) => 'url("data:image/svg+xml;utf8,' + encodeURIComponent(
+ ``) + '")';
+ const SHAPES = {
+ arrow: {
+ width: 24, height: 34, tipX: 2.4, tipY: 2.4,
+ image: svg('0 0 20 28', ''),
+ },
+ hand: {
+ width: 30, height: 35, tipX: 11.5, tipY: 2,
+ image: svg('0 0 26 30', '' +
+ ''),
+ },
+ };
+ // What a mouse would show a hand over: links, buttons and the like, and anything a page gives cursor: pointer.
+ const CLICKABLE = 'a[href], button, summary, label, select, [role="button"], [role="link"], [role="tab"], [role="menuitem"], ' +
+ '[role="option"], input[type="submit"], input[type="button"], input[type="checkbox"], input[type="radio"]';
+ const overClickable = (x, y) => {
+ const el = document.elementFromPoint(x, y);
+ if (!el || el.tagName === 'IFRAME') return false;
+ return Boolean(el.closest(CLICKABLE)) || getComputedStyle(el).cursor === 'pointer';
+ };
let el = null;
+ let shape = '';
let at = { x: 0, y: 0 };
const ensure = () => {
if (el && document.contains(el)) return el;
el = document.createElement('div');
el.id = '__joy-cursor';
el.setAttribute('aria-hidden', 'true');
- el.style.cssText = `position:fixed;left:0;top:0;width:${SIZE}px;height:${SIZE}px;z-index:2147483647;pointer-events:none;display:none;` +
- 'filter:drop-shadow(0 1px 3px rgba(0,0,0,0.55));transition:scale 90ms ease-out;' +
- 'background:url("data:image/svg+xml;utf8,' + encodeURIComponent(ring) + '") no-repeat center / contain;';
+ el.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;display:none;will-change:transform;' +
+ 'background-repeat:no-repeat;background-size:contain;filter:drop-shadow(0 2px 3px rgba(0,0,0,0.45));' +
+ 'transform-origin:var(--tip-x) var(--tip-y);transition:scale 90ms ease-out;';
+ shape = '';
(document.body || document.documentElement).appendChild(el);
return el;
};
+ const setShape = (c, name) => {
+ if (shape === name) return;
+ shape = name;
+ const s = SHAPES[name];
+ c.style.width = `${s.width}px`;
+ c.style.height = `${s.height}px`;
+ c.style.backgroundImage = s.image;
+ c.style.setProperty('--tip-x', `${s.tipX}px`);
+ c.style.setProperty('--tip-y', `${s.tipY}px`);
+ };
+ // Positions arrive from the shell a few dozen times a second, unevenly; the page draws the pointer on every frame of the
+ // screen instead, gliding the rest of the way to the newest one, so it moves smoothly whatever the arrival rate. A click
+ // goes to the newest position, never to the drawn one.
+ let drawn = null;
+ let frame = 0;
+ const paint = () => {
+ frame = 0;
+ if (!el || !drawn) return;
+ drawn.x += (at.x - drawn.x) * 0.5;
+ drawn.y += (at.y - drawn.y) * 0.5;
+ if (Math.abs(at.x - drawn.x) < 0.3 && Math.abs(at.y - drawn.y) < 0.3) drawn = { x: at.x, y: at.y };
+ const s = SHAPES[shape];
+ el.style.transform = `translate(${drawn.x - s.tipX}px, ${drawn.y - s.tipY}px)`;
+ if (drawn.x !== at.x || drawn.y !== at.y) frame = requestAnimationFrame(paint);
+ };
window.__joyCursor = {
show(visible, x, y) {
const c = ensure();
+ const appearing = c.style.display !== 'block';
at = { x, y };
c.style.display = visible ? 'block' : 'none';
- // The click lands on (x, y): the ring is centred on it.
- c.style.transform = `translate(${x - SIZE / 2}px, ${y - SIZE / 2}px)`;
+ if (!visible) return;
+ setShape(c, overClickable(x, y) ? 'hand' : 'arrow');
+ // Appearing, it shows up where it is rather than sliding in from where it was last seen.
+ if (appearing || !drawn) drawn = { x, y };
+ if (!frame) frame = requestAnimationFrame(paint);
},
- // A short squeeze on every click, so a press from the sofa is seen to land.
+ // A short squeeze around the tip on every click, so a press from the sofa is seen to land.
press() {
const c = ensure();
if (c.style.display === 'none') this.show(true, at.x, at.y);
- c.style.scale = '0.75';
+ c.style.scale = '0.82';
setTimeout(() => { c.style.scale = '1'; }, 110);
},
};
diff --git a/app/src/main/controllerActions.ts b/app/src/main/controllerActions.ts
index de0ec86..6c12f9f 100644
--- a/app/src/main/controllerActions.ts
+++ b/app/src/main/controllerActions.ts
@@ -64,6 +64,8 @@ export class VirtualCursor {
x = 200;
y = 200;
placed = false;
+ private drawing = false;
+ private redraw = false;
place(x: number, y: number, width: number, height: number): void {
this.x = Math.min(Math.max(0, x), Math.max(0, width - 1));
@@ -78,7 +80,28 @@ export class VirtualCursor {
async moveBy(wc: WebContents, dx: number, dy: number, width: number, height: number): Promise {
this.place(this.x + dx, this.y + dy, width, height);
wc.sendInputEvent({ type: "mouseMove", x: Math.round(this.x), y: Math.round(this.y) });
- await this.show(wc, true);
+ await this.draw(wc);
+ }
+
+ /**
+ * Draws the pointer where it is now. Moves arrive about 60 times a second; while one drawing is still on its way into
+ * the page, the next ones only ask for another, which then uses the latest position: the pointer never lags behind a
+ * queue of old ones.
+ */
+ private async draw(wc: WebContents): Promise {
+ if (this.drawing) {
+ this.redraw = true;
+ return;
+ }
+ this.drawing = true;
+ try {
+ do {
+ this.redraw = false;
+ await this.show(wc, true);
+ } while (this.redraw);
+ } finally {
+ this.drawing = false;
+ }
}
click(wc: WebContents, button: "left" | "right" = "left"): void {
diff --git a/app/src/main/services/navigation.ts b/app/src/main/services/navigation.ts
index 3bb0d5a..e18cf8c 100644
--- a/app/src/main/services/navigation.ts
+++ b/app/src/main/services/navigation.ts
@@ -8,7 +8,8 @@ export interface NavigationHost {
readonly tabs: TabManager;
readonly settings: () => AppSettings;
readonly httpAllowedHosts: Set;
- openKeyboard(target: "address" | "page"): void;
+ /** The address field takes the focus with the controller keyboard open on it: where a new tab starts. */
+ typeInAddress(): void;
closeKeyboard(): void;
}
@@ -39,6 +40,7 @@ export class Navigation {
newTab(isPrivate = false): void {
const target = isPrivate ? Pages.newtab : newTabTarget(this.host.settings());
this.host.tabs.open(target, { isPrivate });
- if (target !== Pages.newtab) this.host.openKeyboard("address");
+ // A new tab is for going somewhere: it opens on the address field with the keyboard up, whatever page it shows.
+ this.host.typeInAddress();
}
}
diff --git a/app/src/main/shell.ts b/app/src/main/shell.ts
index ec23bca..1d2793e 100644
--- a/app/src/main/shell.ts
+++ b/app/src/main/shell.ts
@@ -115,7 +115,7 @@ export class Shell {
tabs: this.tabs,
settings: () => this.deps.store.current,
httpAllowedHosts: this.httpAllowedHosts,
- openKeyboard: (target) => this.openKeyboard(target),
+ typeInAddress: () => this.typeInAddress(),
closeKeyboard: () => this.closeKeyboard(),
});
this.wireTabs();
@@ -313,6 +313,17 @@ export class Shell {
if (!this.wired.has(tab.webContents)) {
this.wired.add(tab.webContents);
this.attachShortcuts(tab.webContents);
+ // A view takes the window's focus as it is attached (a new tab, about 10 ms after the shell gave its address field the
+ // focus). While the keyboard types into that field the focus stays with the shell, or the D-pad walks dead keys.
+ // The tab hears of its focus before the shell hears it lost it, so the focus goes back once the swap is over.
+ tab.webContents.on("focus", () => {
+ if (this.keyboard?.target !== "address") return;
+ setImmediate(() => {
+ if (this.keyboard?.target !== "address" || this.window.isDestroyed()) return;
+ this.window.webContents.focus();
+ this.chromeFocused = true;
+ });
+ });
}
if (!contentView.children.includes(tab.view)) contentView.addChildView(tab.view);
tab.view.setVisible(tab === this.tabs.active && !this.pageCovered);
@@ -563,6 +574,12 @@ export class Shell {
this.window.webContents.send(Channels.shellFocusChrome);
}
+ /** The toolbar takes the D-pad (see enterChrome) and the keyboard opens on the address field. */
+ typeInAddress(): void {
+ this.enterChrome();
+ this.openKeyboard("address");
+ }
+
leaveChrome(): void {
this.chromeFocused = false;
this.tabs.activeWebContents?.focus();
diff --git a/app/src/renderer/shell/app.tsx b/app/src/renderer/shell/app.tsx
index 2b267b8..d55c383 100644
--- a/app/src/renderer/shell/app.tsx
+++ b/app/src/renderer/shell/app.tsx
@@ -236,13 +236,17 @@ function AddressBar({ url, focused, setFocused, language, keyboard, children }:
const fresh = keyboard && isInternal(url);
const shown = fresh ? "" : url;
const [value, setValue] = useState(shown);
- useEffect(() => { if (!focused) setValue(shown); }, [shown, focused]);
+ // The field follows the tab's address until the user types in it: a new tab focuses the field, and a page that loads
+ // meanwhile must show its address, but text being typed is never overwritten.
+ const edited = useRef(false);
+ useEffect(() => { if (!focused || !edited.current) setValue(shown); }, [shown, focused]);
const submit = () => cmd({ type: "search-or-navigate", text: value });
return (