From 189e2a3e7300c3bbcd519a8e962ea2536dbd7d13 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 16:54:58 -0500 Subject: [PATCH] feat(examples): drag shield keeps the grabbing cursor anywhere while held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging off the grab tile previously reverted the cursor to default, since §7.9 resolves the shape by hit-testing the pointer position. That is the CSS-behavior-correct rule, so the fix lives in userland, not the spec: while held, the demo appends a capture-mode floating overlay that declares grabbing across the viewport. The shield wins the hit test everywhere, freezing hover beneath it exactly like browsers freeze :hover during a drag, and disappears on release. Verified headlessly through the real parser and renderer: grab → grabbing on press, no change dragging to the far corner, default on release in empty space, state clean after. --- examples/README.md | 5 ++-- examples/cursor/index.ts | 13 ++++++---- examples/cursor/view.ts | 54 ++++++++++++++++++++++++++++++++++------ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/examples/README.md b/examples/README.md index 41f4d43..e48e192 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,8 +47,9 @@ What it shows: border-only rounded tiles in Bombshell brand hues, declaring their `pointerShape` (renderer-spec §7.9); hovering a tile shows that exact cursor in terminals that support OSC 22 -- the grab tile is double-wide: holding the pointer down on it shows grabbing, - releasing returns to grab (cursor and label), staying orange throughout +- the grab tile is double-wide: holding the pointer down shows grabbing anywhere + on screen while held — a capture-mode drag shield (userland drag persistence, + matching CSS drag behavior); releasing returns to grab - hover feedback lights the tile's ring and name, so the hover state reads even in terminals without OSC 22 - a muted status line whose hover label takes the hovered tile's hue, with a diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 10870d2..66e8361 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -9,9 +9,9 @@ * bomb.sh); hovering lights the tile's ring and name, so the hover state * reads even in terminals without OSC 22. * - The grab tile is double-wide: holding the pointer down on it shows - * grabbing, releasing returns to grab — real drag semantics, tracked - * through the renderer's hit-test (pointerenter/pointerleave) plus the - * parser's mousedown/mouseup. + * grabbing anywhere on screen while held (a capture-mode drag shield — + * userland drag persistence, no spec support needed), releasing returns + * to grab. * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty * drops help, progress, wait, move, zoom-in, zoom-out, and none. * @@ -40,7 +40,7 @@ import { import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; -import { type Ctx, frame } from "./view.ts"; +import { type Ctx, frame, withDragShield } from "./view.ts"; await main(function* () { let { columns, rows } = terminalSize(); @@ -131,7 +131,10 @@ await main(function* () { ctx.capsOn = term.capabilities.pointerShape; - let { output, events } = term.render(frame(ctx), { pointer: ctx.pointer }); + let { output, events } = term.render( + withDragShield(frame(ctx), columns, rows, ctx.grabbing), + { pointer: ctx.pointer }, + ); for (let e of events) { yield* pointerEvents.send(e); } diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index e911e51..f290660 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -6,8 +6,10 @@ * border-only rounded boxes — no painted fills — with the Bombshell brand * hues from bomb.sh. Hovering lights the tile's ring and name in its hue, * so the hover state reads even in terminals without OSC 22. The grab tile - * is double-wide and shows grabbing while the pointer is held down on it, - * returning to grab on release, staying orange throughout. + * is double-wide and shows grabbing while the pointer is held down on it — + * a capture-mode drag shield keeps the grabbing cursor anywhere on screen + * until release, matching CSS drag behavior — returning to grab on release, + * staying orange throughout. */ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; @@ -62,6 +64,38 @@ const TILES = [ type Tile = (typeof TILES)[number]; +/** + * A capture-mode floating overlay that declares `grabbing` across the + * viewport — the userland drag-persistence pattern. While held, the shield + * wins the hit test everywhere (tiles beneath stop being hovered, like CSS + * freezing :hover during a drag), so the grabbing cursor persists anywhere + * until the button is released. No spec support needed. + */ +export function withDragShield( + ops: Op[], + cols: number, + rows: number, + held: boolean, +): Op[] { + if (!held) return ops; + let out = ops.slice(); + out.splice( + -1, + 0, + open("drag-shield", { + layout: { width: fixed(cols), height: fixed(rows) }, + floating: { + attachTo: "root", + attachPoints: { element: "left-top", parent: "left-top" }, + pointerCaptureMode: "capture", + }, + pointerShape: "grabbing", + }), + close(), + ); + return out; +} + function tile(ops: Op[], t: Tile, ctx: Ctx): void { let id = `shape:${t.shape}`; let hovered = ctx.entered.has(id); @@ -142,14 +176,18 @@ export function frame(ctx: Ctx): Op[] { // Status: a muted bar whose hover label takes the hovered tile's hue, with // a grow spacer pinning the feature indicator to the right edge so both // ends stay put as the hovered name changes length. - let hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.shape}`)); let hoveredShape: string | undefined; let hoveredHue = dim; - if (hoveredTile) { - hoveredShape = "toggle" in hoveredTile && ctx.grabbing - ? hoveredTile.toggle - : hoveredTile.shape; - hoveredHue = hoveredTile.hue; + if (ctx.grabbing) { + // While held, the drag shield is the hovered element. + hoveredShape = "grabbing"; + hoveredHue = BRAND.orange; + } else { + let hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.shape}`)); + if (hoveredTile) { + hoveredShape = hoveredTile.shape; + hoveredHue = hoveredTile.hue; + } } let indicator = ctx.capsOn ? "●" : "■"; let indicatorColor = ctx.capsOn ? BRAND.green : BRAND.red; -- 2.51.2