git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

commitcc30856e608da2ace673624c254e5f88e341414a
parentd101ef581a
authorLucas Galante <[email protected]>
date2026-07-21 22:07
feat: view-mode pinch-zoom/pan, trackpad-style drag, cursor marker

Two fingers in view mode now pinch-zoom (about the pinch centroid, 1-8x,
edge-clamped) and pan the view โ€” a pure CSS transform, so the tap
mapping stays correct because getBoundingClientRect reflects it. Scroll
stays a trackpad-mode gesture. One-finger drag sends relative pointer
moves in both modes; since the compositor's window-stream frames are
surface textures with no cursor composited, view mode draws its own
marker โ€” a cyan ring placed by polling pointer-location over the WS
(new pl/ploc message) through the inverse tap mapping, zoom-aware.

Co-Authored-By: Claude Fable 5 <[email protected]>

 README.md   |  6 +++--
 index.html  | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 src/main.rs | 13 ++++++++++
 3 files changed, 93 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index 2765b54..e523356 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,10 @@ Home Screen for a fullscreen app feel.
 - ๐Ÿ–ฅ โ€” window view mode: a live MJPEG stream of the focused window
   (damage-driven wlr-screencopy: ~11 fps when the window is active, idle
   throttled to output damage; grim remains as a fallback path).
-  Tap to click that spot, long-press to right-click, two-finger drag to
-  scroll. Toggle again for the trackpad. Both `/stream` and the one-shot
+  Tap to click that spot, long-press to right-click; one-finger drag moves
+  the pointer exactly like the trackpad (a cyan ring marks the cursor โ€”
+  compositor frames carry none), and two fingers pinch-zoom / pan the view
+  itself. Toggle again for the trackpad. Both `/stream` and the one-shot
   `/shot` endpoint are PIN-gated; nothing accumulates on disk.
 
 ## Security
diff --git a/index.html b/index.html
index 59bae55..74015f5 100644
--- a/index.html
+++ b/index.html
@@ -29,7 +29,12 @@
   #clicks button:active, #bar button:active { background: #2c5f69; }
   #kb { position: absolute; opacity: 0.02; width: 1px; height: 1px; left: -10px; top: -10px; }
   #screen { display: none; position: absolute; inset: 0; width: 100%; height: 100%;
-            object-fit: contain; background: #0c0d12; border-radius: 12px; z-index: 2; }
+            object-fit: contain; background: #0c0d12; border-radius: 12px; z-index: 2;
+            transform-origin: 0 0; will-change: transform; }
+  #vcursor { display: none; position: absolute; width: 14px; height: 14px; z-index: 3;
+             pointer-events: none; border: 2px solid #7dffff; border-radius: 50%;
+             box-shadow: 0 0 4px #000; margin: -7px 0 0 -7px; }
+  #pad.view #vcursor.on { display: block; }
   #pad.view #screen { display: block; }
   #pad.view .hint { display: none; }
   #switcher, #menu { display: none; position: absolute; inset: 8px; overflow-y: auto;
@@ -72,6 +77,7 @@
 <div id="preview"></div>
 <div id="pad"><div class="hint">drag = move ยท tap = click ยท 2-finger drag = scroll<br>2-finger tap = right click ยท hold = drag</div>
   <img id="screen" draggable="false">
+  <div id="vcursor"></div>
   <div id="switcher"></div>
   <div id="menu"></div>
 </div>
@@ -101,6 +107,7 @@ function connect() {
   };
   ws.onmessage = e => {
     if (typeof e.data === "string" && e.data.startsWith("windows ")) { renderSwitcher(e.data.slice(8)); }
+    else if (typeof e.data === "string" && e.data.startsWith("ploc ")) { updateCursorMarker(e.data.slice(5)); }
     else if (e.data === "auth ok") { authed = true; status.textContent = "โ—"; status.style.color = "#5fbf6f"; }
     else if (e.data === "auth fail") {
       localStorage.removeItem("cce-remote-pin");
@@ -136,12 +143,45 @@ let dragging = false, dragTimer = null, pendDx = 0, pendDy = 0, pendSy = 0, flus
 // click it. The <img> renders /stream (multipart/x-mixed-replace)
 // natively; winRect for tap mapping is kept fresh by polling `wl` over
 // the WS (the stream follows focus server-side).
-let viewMode = false, winRect = null, rectTimer = null;
+let viewMode = false, winRect = null, rectTimer = null, cursorTimer = null;
 const screenEl = document.getElementById("screen");
 function updateRect(wins) {
   const f = wins.find(w => w.focused && w.mode !== "Status");
   if (f) winRect = { id: f.id, x: f.x, y: f.y, w: f.w, h: f.h };
 }
+// View zoom/pan (two-finger pinch/drag in view mode): a pure client-side
+// transform of the streamed image. Uniform scale+translate means
+// getBoundingClientRect reflects it, so mapToWindow stays correct zoomed.
+let vZoom = 1, vPanX = 0, vPanY = 0, pinch = null;
+function applyViewTransform() {
+  screenEl.style.transform = "translate(" + vPanX + "px," + vPanY + "px) scale(" + vZoom + ")";
+}
+function resetViewTransform() { vZoom = 1; vPanX = 0; vPanY = 0; pinch = null; applyViewTransform(); }
+function clampPan() {
+  const r = pad.getBoundingClientRect();
+  vPanX = Math.min(Math.max(vPanX, r.width - r.width * vZoom), 0);
+  vPanY = Math.min(Math.max(vPanY, r.height - r.height * vZoom), 0);
+  if (vZoom <= 1.001) { vZoom = 1; vPanX = 0; vPanY = 0; }
+}
+
+// The compositor's frames carry no cursor (they're surface textures), so
+// view mode draws its own marker: poll pointer-location over the WS and
+// place the ring via the inverse of the tap mapping.
+const vcursor = document.getElementById("vcursor");
+function updateCursorMarker(coords) {
+  if (!viewMode || !winRect || !screenEl.naturalWidth) { vcursor.classList.remove("on"); return; }
+  const [lx, ly] = coords.split(" ").map(Number);
+  const u = (lx - winRect.x) / winRect.w, v = (ly - winRect.y) / winRect.h;
+  if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) { vcursor.classList.remove("on"); return; }
+  const box = screenEl.getBoundingClientRect();
+  const scale = Math.min(box.width / screenEl.naturalWidth, box.height / screenEl.naturalHeight);
+  const dw = screenEl.naturalWidth * scale, dh = screenEl.naturalHeight * scale;
+  const padBox = pad.getBoundingClientRect();
+  vcursor.style.left = (box.left - padBox.left + (box.width - dw) / 2 + u * dw) + "px";
+  vcursor.style.top = (box.top - padBox.top + (box.height - dh) / 2 + v * dh) + "px";
+  vcursor.classList.add("on");
+}
+
 // touch point (viewport px) โ†’ layout px inside the focused window,
 // accounting for the contain-fit letterbox. null when off the image.
 function mapToWindow(cx, cy) {
@@ -171,7 +211,7 @@ pad.addEventListener("touchstart", e => {
   for (const t of e.changedTouches) touches.set(t.identifier, { x: t.clientX, y: t.clientY });
   maxFingers = Math.max(maxFingers, touches.size);
   if (touches.size === 1) {
-    moved = 0; startT = Date.now();
+    moved = 0; startT = Date.now(); pinch = null;
     const t0 = e.changedTouches[0];
     startX = t0.clientX; startY = t0.clientY;
     dragTimer = setTimeout(() => {
@@ -193,18 +233,42 @@ pad.addEventListener("touchmove", e => {
   if (touches.size === 1) {
     const t = e.changedTouches[0], p = touches.get(t.identifier);
     if (!p) return;
-    // view mode: one-finger movement is not pointer motion (taps only)
-    if (!viewMode) { pendDx += t.clientX - p.x; pendDy += t.clientY - p.y; queueFlush(); }
+    // one-finger drag = relative pointer motion, in BOTH modes
+    pendDx += t.clientX - p.x; pendDy += t.clientY - p.y; queueFlush();
     moved += Math.abs(t.clientX - p.x) + Math.abs(t.clientY - p.y);
     touches.set(t.identifier, { x: t.clientX, y: t.clientY });
   } else if (touches.size === 2) {
-    // scroll: average finger dy, natural direction (content follows finger)
     let dy = 0, n = 0;
     for (const t of e.changedTouches) {
       const p = touches.get(t.identifier);
       if (p) { dy += t.clientY - p.y; n++; touches.set(t.identifier, { x: t.clientX, y: t.clientY }); }
     }
-    if (n) { pendSy += (-dy / n) * SCROLL; moved += Math.abs(dy); queueFlush(); }
+    if (n) moved += Math.abs(dy);
+    if (viewMode) {
+      // two fingers in view mode: pinch-zoom + pan of the view
+      const pts = [...touches.values()];
+      if (pts.length === 2) {
+        const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
+        const cx = (pts[0].x + pts[1].x) / 2, cy = (pts[0].y + pts[1].y) / 2;
+        if (pinch && pinch.dist > 0) {
+          const nz = Math.min(8, Math.max(1, vZoom * (dist / pinch.dist)));
+          const applied = nz / vZoom;
+          const r = pad.getBoundingClientRect();
+          const px = cx - r.left, py = cy - r.top;
+          // zoom about the pinch centroid, then follow its drag
+          vPanX = px - (px - vPanX) * applied + (cx - pinch.cx);
+          vPanY = py - (py - vPanY) * applied + (cy - pinch.cy);
+          vZoom = nz;
+          clampPan();
+          applyViewTransform();
+        }
+        pinch = { dist, cx, cy };
+      }
+    } else if (n) {
+      // trackpad mode: two-finger drag scrolls (natural direction)
+      pendSy += (-dy / n) * SCROLL;
+      queueFlush();
+    }
   }
 }, { passive: false });
 
@@ -212,6 +276,7 @@ pad.addEventListener("touchend", e => {
   if (swContains(e.target)) return;
   e.preventDefault();
   for (const t of e.changedTouches) touches.delete(t.identifier);
+  if (touches.size < 2) pinch = null;
   if (touches.size > 0) return;
   clearTimeout(dragTimer);
   flush();
@@ -241,13 +306,17 @@ function setViewMode(on) {
   viewMode = on;
   mbtn.classList.toggle("on", viewMode);
   pad.classList.toggle("view", viewMode);
+  resetViewTransform();
   if (viewMode) {
     const pin = localStorage.getItem("cce-remote-pin") || "";
     screenEl.src = "/stream?pin=" + encodeURIComponent(pin);
     send("wl"); // prime winRect
     rectTimer = setInterval(() => send("wl"), 2000);
+    cursorTimer = setInterval(() => send("pl"), 250);
   } else {
     clearInterval(rectTimer);
+    clearInterval(cursorTimer);
+    vcursor.classList.remove("on");
     screenEl.src = "data:,"; // closes the stream connection
   }
 }
diff --git a/src/main.rs b/src/main.rs
index d559bd2..b389da1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -236,6 +236,19 @@ fn handle_ws(stream: TcpStream, pin: &str) {
                     if text.trim() == "wl" {
                         // Window-list request: the one message with a reply.
                         let _ = ws.send(tungstenite::Message::Text(window_list_json()));
+                    } else if text.trim() == "pl" {
+                        // Pointer location (view mode's cursor marker):
+                        // "x=N y=N" โ†’ "ploc N N".
+                        if let Ok(reply) = control_command("pointer-location") {
+                            let coords: String = reply
+                                .split_whitespace()
+                                .filter_map(|kv| kv.strip_prefix("x=").or_else(|| kv.strip_prefix("y=")))
+                                .collect::<Vec<_>>()
+                                .join(" ");
+                            if !coords.is_empty() {
+                                let _ = ws.send(tungstenite::Message::Text(format!("ploc {coords}")));
+                            }
+                        }
                     } else if let Some((coords, btn)) = text
                         .strip_prefix("tap ")
                         .map(|r| (r, "left"))