feat(collab): follow-user (collab-presence 0008) + chip depth-layer fix

Follow-user: click a peer's roster avatar to mirror their viewport until
local input breaks it.
- collab_presence_core.h: CORE::fitViewport(cx, cy, halfW, halfH) — fit the
  leader's world rect with CONTAIN semantics (zoom derived from the
  follower's own canvas via the ToScreen ratio; GetScale is the zoom, not
  px/IU). Exported as kicadCollabFitViewport from both editor TUs + the
  merged dispatcher.
- presence-kicad.ts: publish the visible world rect (viewportRect) into
  awareness, 100 ms trailing throttle; guarded for pre-0008 handles.
- follow-user.ts: createFollow — follows an awareness CLIENT (a tab, not a
  user); applies leader rect changes via FitViewport, dedupes unchanged
  republishes; break-on-interact compares local onViewport echoes against
  the last applied rect (2% rel tolerance, echo-grace before the first fit
  lands); unfollows on leader-left; pauses on eeschema sheet mismatch.
- PresenceRoster: avatars are follow toggles (ring on the followed peer);
  WasmTool renders the "Following <name> — move to stop" banner.
- tests: 7 controller units (85/85 collab), fitViewport round-trip e2e in
  both kicad presence specs (20/20), two-tab tests/web/follow.spec.ts
  (converge → track → wheel-zoom breaks → subsequent moves ignored).

Chip depth-layer fix (user-reported): name chips washed out inside
low-alpha selection fills — chip rects shared the shapes overlay's single
depth, and same-depth fragments drawn LATER lose the depth test, so an
earlier-painted fill rejected the chip's pixels. Now three layers via the
fork's VIEW_OVERLAY::SetDepthOffset: text (0) < chips + pin dots (1) <
selection shapes (2). drawLabel/drawCursor/drawSelectionBox take the chip
overlay explicitly; comment-pin dots move to the chip layer too (the 0005
"drawn last so pins sit above" comment had the rule backwards). Verified
with a chip-inside-30%-fill pixel repro + the full presence suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
This commit is contained in:
Gergő Törcsvári 2026-07-09 09:30:40 +02:00
commit 1f85e13acf
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
16 changed files with 821 additions and 52 deletions

View file

@ -425,6 +425,52 @@ test("cross-app ghost render: a pcbnew peer's xsel uuid resolves on the current
expect(hasAbort(testLogger)).toBe(false); expect(hasAbort(testLogger)).toBe(false);
}); });
// Follow-user (0008): FitViewport applies a world rect with contain semantics —
// verified through the GetViewport round trip (center lands, the contained
// axis' half-extent matches the request within the fit's own tolerance).
test("fitViewport applies a world rect (contain) — GetViewport round trip", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const target = { cx: 120e6, cy: 90e6, halfW: 40e6, halfH: 30e6 };
await page.evaluate((t) => {
const m = (window as unknown as PresenceWindow).Module as unknown as {
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
};
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
}, target);
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
await expect
.poll(
async () => {
const vp = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
);
return Math.abs(vp.cx - target.cx) < 1e6 && Math.abs(vp.cy - target.cy) < 1e6;
},
{ timeout: 20000, intervals: [250] },
)
.toBe(true);
const vp = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
);
// Contain: at least one axis' visible half-extent matches the request (the
// other overshoots by the canvas aspect), and neither is SMALLER (cropping).
const halfW = vp.w / 2 / vp.scale;
const halfH = vp.h / 2 / vp.scale;
expect(halfW).toBeGreaterThan(target.halfW * 0.95);
expect(halfH).toBeGreaterThan(target.halfH * 0.95);
const relW = Math.abs(halfW - target.halfW) / target.halfW;
const relH = Math.abs(halfH - target.halfH) / target.halfH;
expect(Math.min(relW, relH)).toBeLessThan(0.05);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => { test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page); await bootAndOpen(page);

View file

@ -518,6 +518,52 @@ test("cross-app ghost render: an eeschema peer's symbol uuid maps to the footpri
expect(hasAbort(testLogger)).toBe(false); expect(hasAbort(testLogger)).toBe(false);
}); });
// Follow-user (0008): FitViewport applies a world rect with contain semantics —
// verified through the GetViewport round trip (center lands, the contained
// axis' half-extent matches the request within the fit's own tolerance).
test("fitViewport applies a world rect (contain) — GetViewport round trip", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const target = { cx: 120e6, cy: 90e6, halfW: 40e6, halfH: 30e6 };
await page.evaluate((t) => {
const m = (window as unknown as PresenceWindow).Module as unknown as {
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
};
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
}, target);
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
await expect
.poll(
async () => {
const vp = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
);
return Math.abs(vp.cx - target.cx) < 1e6 && Math.abs(vp.cy - target.cy) < 1e6;
},
{ timeout: 20000, intervals: [250] },
)
.toBe(true);
const vp = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
);
// Contain: at least one axis' visible half-extent matches the request (the
// other overshoots by the canvas aspect), and neither is SMALLER (cropping).
const halfW = vp.w / 2 / vp.scale;
const halfH = vp.h / 2 / vp.scale;
expect(halfW).toBeGreaterThan(target.halfW * 0.95);
expect(halfH).toBeGreaterThan(target.halfH * 0.95);
const relW = Math.abs(halfW - target.halfW) / target.halfW;
const relH = Math.abs(halfH - target.halfH) / target.halfH;
expect(Math.min(relW, relH)).toBeLessThan(0.05);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => { test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page); await bootAndOpen(page);

125
tests/web/follow.spec.ts Normal file
View file

@ -0,0 +1,125 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Follow-user e2e (collab-presence 0008): two tabs on the demo BOARD share
* the doc room's awareness. Tab B clicks tab A's roster avatar → B's viewport
* continuously mirrors A's published world rect (contain-fit); B's own canvas
* input breaks the follow.
*
* Assertions use the deterministic viewport transforms
* (`kicadCollabGetViewport` / `kicadCollabFitViewport`) — the fit math itself
* is covered per-editor in tests/kicad; this spec covers the wire + controller
* loop: publish (100 ms trailing) → awareness → follow apply → echo
* suppression → break-on-interact.
*/
const SCOPE = 'default';
type Mod = {
kicadCollabGetViewport(): string;
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
};
type W = { Module: Mod };
type Vp = { cx: number; cy: number; scale: number; w: number; h: number };
async function bootBoard(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), {
message: `${user}: editor never reached the expected title`,
timeout: 120000,
intervals: [1000],
})
.toMatch(/demo — PCB Editor/i);
await page.waitForFunction(
() => {
const m = (window as unknown as Partial<W>).Module;
return (
typeof m?.kicadCollabGetViewport === 'function' &&
typeof m?.kicadCollabFitViewport === 'function'
);
},
null,
{ timeout: 60000 },
);
}
const viewport = (page: Page): Promise<Vp> =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()),
);
const fit = (page: Page, cx: number, cy: number, hw: number, hh: number) =>
page.evaluate(
(t) => (window as unknown as W).Module.kicadCollabFitViewport(t.cx, t.cy, t.hw, t.hh),
{ cx, cy, hw, hh },
);
/** Center-convergence check: within 2 % of the target half-width. */
const near = (vp: Vp, cx: number, cy: number, tol: number) =>
Math.abs(vp.cx - cx) < tol && Math.abs(vp.cy - cy) < tol;
test('B follows A: viewport mirrors, then local input breaks the follow', async ({
page,
context,
}) => {
test.setTimeout(480000); // two full pcbnew wasm boots
const a = page;
await bootBoard(a, 'alice');
const b = await context.newPage();
await bootBoard(b, 'bob');
// A frames a distinctive region (world IU; demo board is an A4 sheet).
const T1 = { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 };
await fit(a, T1.cx, T1.cy, T1.hw, T1.hh);
await expect
.poll(async () => near(await viewport(a), T1.cx, T1.cy, 1e6), {
timeout: 20000,
message: 'A never landed on its own fit target',
})
.toBe(true);
// B sees alice in the roster and clicks her avatar → follow.
const avatar = b.locator('[data-presence-user="alice"]');
await expect(avatar).toBeVisible({ timeout: 30000 });
await avatar.click();
await expect(b.getByTestId('follow-banner')).toBeVisible({ timeout: 10000 });
// B's viewport converges on A's rect (publish 100 ms + awareness relay).
await expect
.poll(async () => near(await viewport(b), T1.cx, T1.cy, T1.hw * 0.02), {
timeout: 30000,
message: 'B never converged on the followed viewport',
})
.toBe(true);
// A moves again → B tracks.
const T2 = { cx: 180e6, cy: 120e6, hw: 25e6, hh: 20e6 };
await fit(a, T2.cx, T2.cy, T2.hw, T2.hh);
await expect
.poll(async () => near(await viewport(b), T2.cx, T2.cy, T2.hw * 0.02), {
timeout: 30000,
message: 'B never tracked the second viewport move',
})
.toBe(true);
// B interacts (real wheel zoom on the canvas) → the follow breaks.
const canvasBox = await b.locator('#canvas').boundingBox();
if (!canvasBox) throw new Error('no canvas box');
await b.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
await b.mouse.wheel(0, -240);
await expect(b.getByTestId('follow-banner')).toBeHidden({ timeout: 15000 });
// A moves once more; B (unfollowed) must NOT track it. A landing on its own
// target is the synchronization point — by then A's rect went out and B
// demonstrably ignored it (its center is still near T2, zoom aside).
const T3 = { cx: 80e6, cy: 60e6, hw: 30e6, hh: 25e6 };
await fit(a, T3.cx, T3.cy, T3.hw, T3.hh);
await expect
.poll(async () => near(await viewport(a), T3.cx, T3.cy, 1e6), { timeout: 20000 })
.toBe(true);
const bVp = await viewport(b);
expect(near(bVp, T3.cx, T3.cy, T3.hw * 0.05), 'B tracked A after unfollow').toBe(false);
});

View file

@ -128,8 +128,11 @@ struct CORE
STYLE style; STYLE style;
std::shared_ptr<KIGFX::VIEW_OVERLAY> overlay; std::shared_ptr<KIGFX::VIEW_OVERLAY> overlay;
// Labels render from their own overlay at the nearest depth (chip rects // Name chips + pin dots render one depth unit ABOVE the selection shapes
// would otherwise erase same-depth text) — see PRESENCE_TEXT_OVERLAY. // (else an earlier-painted low-alpha fill rejects the chip's fragments and
// the tag washes out to the fill's alpha), and the text one unit above the
// chips — see the depth-layering note in collab_presence_style.h.
std::shared_ptr<KIGFX::VIEW_OVERLAY> chipOverlay;
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay; std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
bool started = false; bool started = false;
@ -235,6 +238,38 @@ struct CORE
{ "w", sz.x }, { "h", sz.y } }.dump(); { "w", sz.x }, { "h", sz.y } }.dump();
} }
/** kicadCollabFitViewport (0008 follow-user): fit the given world RECT
* (center + half-extents, IU) into this canvas — contain, never crop:
* the follower's zoom is derived from ITS OWN canvas size, so leaders
* and followers on different monitors see the same world region.
* Fiber like every other view mutation from JS. */
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
EDA_DRAW_FRAME* fr = frame();
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
const VECTOR2I& sz = view->GetScreenPixelSize();
// Pixels-per-IU that CONTAINS the rect in both axes. GetScale() is
// the zoom, not px/IU (0002 lesson) — convert via the GAL matrix:
// zoom scales linearly with px/IU, so target zoom = current zoom ×
// (target px/IU ÷ current px/IU).
double pxPerIuNow = view->ToScreen( 1.0 );
double pxPerIuFit = std::min( sz.x / ( 2.0 * aHalfW ), sz.y / ( 2.0 * aHalfH ) );
if( pxPerIuNow > 0 && pxPerIuFit > 0 )
view->SetScale( view->GetScale() * ( pxPerIuFit / pxPerIuNow ) );
view->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
} );
}
/** kicadCollabSetViewport (0005): pan to a world position (comment panel /** kicadCollabSetViewport (0005): pan to a world position (comment panel
* "jump to pin"). Fiber like every other view mutation from JS. */ * "jump to pin"). Fiber like every other view mutation from JS. */
void panTo( double aCx, double aCy ) void panTo( double aCx, double aCy )
@ -300,16 +335,23 @@ struct CORE
if( !overlay ) if( !overlay )
{ {
// Depth layering near→deep: text (0) < chips (1) < shapes (2) —
// fork SetDepthOffset; see collab_presence_style.h.
overlay = view->MakeOverlay(); overlay = view->MakeOverlay();
// Shapes sit one depth unit BELOW the text overlay (fork
// SetDepthOffset) so labels always render on top of chips/boxes.
overlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET ); overlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
} }
if( !chipOverlay )
{
chipOverlay = view->MakeOverlay();
chipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
}
if( !textOverlay ) if( !textOverlay )
textOverlay = makePresenceTextOverlay( view ); textOverlay = makePresenceTextOverlay( view );
overlay->Clear(); overlay->Clear();
chipOverlay->Clear();
textOverlay->Clear(); textOverlay->Clear();
// Screen-constant sizing: px → world units, so cursors/outline widths // Screen-constant sizing: px → world units, so cursors/outline widths
@ -326,18 +368,20 @@ struct CORE
drawPeerShapes( *this, fr, peer, color, px ); drawPeerShapes( *this, fr, peer, color, px );
if( peer.hasCursor ) if( peer.hasCursor )
drawCursor( overlay.get(), textOverlay.get(), peer.cursor, peer.name, color, drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor,
px, style ); peer.name, color, px, style );
} }
// Comment pin dots (0005), drawn last so they sit above selection outlines. // Comment pin dots (0005) — on the CHIPS layer so selection fills
// can't reject their fragments (see drawPin).
for( const PIN& pin : pins ) for( const PIN& pin : pins )
{ {
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color ); KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( overlay.get(), pin.pos, color, pin.resolved, px, style ); drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, px, style );
} }
view->Update( overlay.get() ); view->Update( overlay.get() );
view->Update( chipOverlay.get() );
view->Update( textOverlay.get() ); view->Update( textOverlay.get() );
// The canvas repaints on its own only with focus/input — force it, // The canvas repaints on its own only with focus/input — force it,
// exactly as the cross-probe flash does. // exactly as the cross-probe flash does.

View file

@ -245,8 +245,18 @@ public:
} }
}; };
/** Depth offset for the SHAPES overlay — one unit deeper than the text. */ /**
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 1.0; * Depth layering (0007 lesson, extended): each overlay draws its WHOLE
* command list at one depth, and same-depth fragments drawn later LOSE the
* depth test — so anything that must render on top of something else needs
* its own overlay one unit nearer. Three layers, near → deep:
* text (0) < chips + pin dots (1) < selection shapes/fills (2)
* Chips above fills fixes the washed-out name tags inside low-alpha
* selection areas (the chip fragments were rejected against the
* earlier-drawn fill, leaving only the fill's alpha behind the glyphs).
*/
constexpr double PRESENCE_CHIPS_DEPTH_OFFSET = 1.0;
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 2.0;
/** VIEW::MakeOverlay's body, for the text overlay (make + Add to the view). */ /** VIEW::MakeOverlay's body, for the text overlay (make + Add to the view). */
inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VIEW* aView ) inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VIEW* aView )
@ -257,12 +267,13 @@ inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VI
} }
/** Name tag next to (or inside) a box, per the label placement knobs. `px` is /** Name tag next to (or inside) a box, per the label placement knobs. `px` is
* world-units-per-screen-pixel. The chip rect goes to the SHAPES overlay, * world-units-per-screen-pixel. The chip rect goes to the CHIPS overlay
* the text to the TEXT overlay (nearest depth + pinned TOP-LEFT justify — * (above selection fills, below text), the text to the TEXT overlay (nearest
* see PRESENCE_TEXT_OVERLAY), so the text always renders on top. */ * depth + pinned TOP-LEFT justify — see PRESENCE_TEXT_OVERLAY), so the tag
inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, const BOX2I& aBox, * renders solid regardless of what selection geometry it overlaps. */
const std::string& aText, const KIGFX::COLOR4D& aColor, double aPx, inline void drawLabel( KIGFX::VIEW_OVERLAY* aChipOv, KIGFX::VIEW_OVERLAY* aTextOv,
const STYLE& aS ) const BOX2I& aBox, const std::string& aText,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{ {
if( !aS.labelShow || aText.empty() ) if( !aS.labelShow || aText.empty() )
return; return;
@ -288,13 +299,13 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
if( aS.labelChip ) if( aS.labelChip )
{ {
double padX = 3 * aPx, padY = 2 * aPx; double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false ); aChipOv->SetIsStroke( false );
aOv->SetIsFill( true ); aChipOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) ); aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aOv->Rectangle( VECTOR2D( x - padX, y - padY ), aChipOv->Rectangle( VECTOR2D( x - padX, y - padY ),
VECTOR2D( x + w + padX, y + h + padY ) ); VECTOR2D( x + w + padX, y + h + padY ) );
aOv->SetIsStroke( true ); aChipOv->SetIsStroke( true );
aOv->SetIsFill( false ); aChipOv->SetIsFill( false );
} }
aTextOv->SetIsStroke( true ); aTextOv->SetIsStroke( true );
@ -307,8 +318,11 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
/** Selection highlight for one item, in the chosen shape. `aOutline` is the /** Selection highlight for one item, in the chosen shape. `aOutline` is the
* item's exact geometry for selShape 5 (pcbnew supplies it; eeschema passes * item's exact geometry for selShape 5 (pcbnew supplies it; eeschema passes
* nullptr and shape 5 falls back to the bbox rectangle). */ * nullptr and shape 5 falls back to the bbox rectangle). Shapes/fills paint
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox, * on `aOv` (deepest layer); the name chip goes to `aChipOv` one unit nearer
* so it stays solid over any fill (see the depth-layering note above). */
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox,
const std::string& aName, const KIGFX::COLOR4D& aColor, double aPx, const std::string& aName, const KIGFX::COLOR4D& aColor, double aPx,
const STYLE& aS, const SHAPE_POLY_SET* aOutline = nullptr ) const STYLE& aS, const SHAPE_POLY_SET* aOutline = nullptr )
{ {
@ -323,7 +337,7 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
BOX2I labelBox = aOutline->BBox(); BOX2I labelBox = aOutline->BBox();
labelBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) ); labelBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
drawLabel( aOv, aTextOv, labelBox, aName, aColor, aPx, aS ); drawLabel( aChipOv, aTextOv, labelBox, aName, aColor, aPx, aS );
return; return;
} }
@ -403,11 +417,13 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
} }
} }
drawLabel( aOv, aTextOv, aBox, aName, aColor, aPx, aS ); drawLabel( aChipOv, aTextOv, aBox, aName, aColor, aPx, aS );
} }
/** Remote cursor (+ name label) in the chosen shape. */ /** Remote cursor (+ name label) in the chosen shape. The cursor glyph paints
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, * on `aOv`; its label chip on `aChipOv` (same layering as drawSelectionBox). */
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
KIGFX::VIEW_OVERLAY* aTextOv,
const VECTOR2D& aPos, const std::string& aName, const VECTOR2D& aPos, const std::string& aName,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS ) const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{ {
@ -455,18 +471,18 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx, VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx,
( aS.cursorSizePx + 4 ) * aPx ); ( aS.cursorSizePx + 4 ) * aPx );
// Chip rect on the shapes overlay, text on the TEXT overlay (nearest // Chip rect on the CHIPS overlay, text on the TEXT overlay (nearest
// depth) — text always renders on top of its chip. // depth) — text on top of its chip, chip on top of selection fills.
if( aS.cursorLabelChip ) if( aS.cursorLabelChip )
{ {
double padX = 3 * aPx, padY = 2 * aPx; double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false ); aChipOv->SetIsStroke( false );
aOv->SetIsFill( true ); aChipOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) ); aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aOv->Rectangle( at + VECTOR2D( -padX, -padY ), aChipOv->Rectangle( at + VECTOR2D( -padX, -padY ),
at + VECTOR2D( w + padX, h + padY ) ); at + VECTOR2D( w + padX, h + padY ) );
aOv->SetIsStroke( true ); aChipOv->SetIsStroke( true );
aOv->SetIsFill( false ); aChipOv->SetIsFill( false );
} }
aTextOv->SetIsStroke( true ); aTextOv->SetIsStroke( true );
@ -478,7 +494,9 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
} }
} }
/** Comment pin dot. */ /** Comment pin dot. Draw onto the CHIPS overlay: at the shapes depth an
* earlier-painted selection fill would reject the dot's fragments (the 0005
* "drawn last sits above" comment had it backwards — later fragments LOSE). */
inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor, inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor,
bool aResolved, double aPx, const STYLE& aS ) bool aResolved, double aPx, const STYLE& aS )
{ {

View file

@ -733,7 +733,8 @@ pcbjam_presence::CORE& presenceCore()
if( !item ) if( !item )
continue; // not in this schematic (yet) — skip silently continue; // not in this schematic (yet) — skip silently
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(), pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), peer.name, color, px, item->ViewBBox(), peer.name, color, px,
aCore.style ); aCore.style );
} }
@ -745,7 +746,8 @@ pcbjam_presence::CORE& presenceCore()
for( SCH_ITEM* item : resolveXsel( fr, peer ) ) for( SCH_ITEM* item : resolveXsel( fr, peer ) )
{ {
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(), pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), peer.name, color, px, item->ViewBBox(), peer.name, color, px,
ghost ); ghost );
} }
@ -1467,6 +1469,12 @@ void schCollabSetViewport( double aCx, double aCy )
presenceCore().panTo( aCx, aCy ); presenceCore().panTo( aCx, aCy );
} }
// JS → C++ (0008 follow-user): fit a leader's world rect into this canvas.
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
}
// JS pull of the current viewport transform: `{cx,cy,scale,w,h}` with scale = px per // JS pull of the current viewport transform: `{cx,cy,scale,w,h}` with scale = px per
// IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson). // IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson).
std::string schCollabGetViewport() std::string schCollabGetViewport()
@ -1691,6 +1699,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabSetRemote", &schCollabSetRemote); function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabSetPins", &schCollabSetPins); function("kicadCollabSetPins", &schCollabSetPins);
function("kicadCollabSetViewport", &schCollabSetViewport); function("kicadCollabSetViewport", &schCollabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &schCollabFitViewport);
function("kicadCollabSetStyle", &schCollabSetStyle); function("kicadCollabSetStyle", &schCollabSetStyle);
function("kicadCollabTestListItems", &schCollabTestListItems); function("kicadCollabTestListItems", &schCollabTestListItems);
function("kicadCollabTestDemoSet", &schCollabTestDemoSet); function("kicadCollabTestDemoSet", &schCollabTestDemoSet);

View file

@ -58,6 +58,8 @@ void pcbCollabPresenceStart();
void pcbCollabSetRemote( std::string aJson ); void pcbCollabSetRemote( std::string aJson );
void pcbCollabSetPins( std::string aJson ); void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy ); void pcbCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
void pcbCollabSetStyle( std::string aJson ); void pcbCollabSetStyle( std::string aJson );
std::string pcbCollabTestListItems( int aCount ); std::string pcbCollabTestListItems( int aCount );
std::string pcbCollabTestDemoSet(); std::string pcbCollabTestDemoSet();
@ -90,6 +92,8 @@ void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson ); void schCollabSetRemote( std::string aJson );
void schCollabSetPins( std::string aJson ); void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy ); void schCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
void schCollabSetStyle( std::string aJson ); void schCollabSetStyle( std::string aJson );
std::string schCollabTestListItems( int aCount ); std::string schCollabTestListItems( int aCount );
std::string schCollabTestDemoSet(); std::string schCollabTestDemoSet();
@ -337,6 +341,13 @@ static void collabSetViewport( double aCx, double aCy )
pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy ); pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy );
} }
// Follow-user (collab-presence 0008).
static void collabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
pcbEditorActive() ? pcbCollabFitViewport( aCx, aCy, aHalfW, aHalfH )
: schCollabFitViewport( aCx, aCy, aHalfW, aHalfH );
}
static void collabSetStyle( std::string aJson ) static void collabSetStyle( std::string aJson )
{ {
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson ); pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson );
@ -426,6 +437,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabSetRemote", &collabSetRemote); function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetPins", &collabSetPins); function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport); function("kicadCollabSetViewport", &collabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &collabFitViewport);
function("kicadCollabSetStyle", &collabSetStyle); function("kicadCollabSetStyle", &collabSetStyle);
function("kicadCollabTestListItems", &collabTestListItems); function("kicadCollabTestListItems", &collabTestListItems);
function("kicadCollabTestDemoSet", &collabTestDemoSet); function("kicadCollabTestDemoSet", &collabTestDemoSet);

View file

@ -1203,7 +1203,8 @@ pcbjam_presence::CORE& presenceCore()
} }
} }
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(), pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), name, itemColor, px, item->ViewBBox(), name, itemColor, px,
style, &outline ); style, &outline );
}; };
@ -1594,6 +1595,12 @@ void pcbCollabSetViewport( double aCx, double aCy )
presenceCore().panTo( aCx, aCy ); presenceCore().panTo( aCx, aCy );
} }
// JS → C++ (0008 follow-user): fit a leader's world rect into this canvas.
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
}
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer): // JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px. // `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
std::string pcbCollabGetViewport() std::string pcbCollabGetViewport()
@ -1981,6 +1988,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabSetRemote", &pcbCollabSetRemote); function("kicadCollabSetRemote", &pcbCollabSetRemote);
function("kicadCollabSetPins", &pcbCollabSetPins); function("kicadCollabSetPins", &pcbCollabSetPins);
function("kicadCollabSetViewport", &pcbCollabSetViewport); function("kicadCollabSetViewport", &pcbCollabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &pcbCollabFitViewport);
function("kicadCollabSetStyle", &pcbCollabSetStyle); function("kicadCollabSetStyle", &pcbCollabSetStyle);
function("kicadCollabTestListItems", &pcbCollabTestListItems); function("kicadCollabTestListItems", &pcbCollabTestListItems);
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet); function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);

@ -1 +1 @@
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5 Subproject commit 1c4103c583b7326245a288a5ff63efe18dafcbf1

View file

@ -1,4 +1,5 @@
import type { PresencePeer } from "@/wasm/collab/presence"; import type { PresencePeer } from "@/wasm/collab/presence";
import type { FollowTarget } from "@/wasm/collab/follow-user";
/** /**
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of * "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
@ -8,6 +9,11 @@ import type { PresencePeer } from "@/wasm/collab/presence";
* they're on in the tooltip. Rendered in the editor's top-right overlay stack * they're on in the tooltip. Rendered in the editor's top-right overlay stack
* next to SourceChip; the parent hides it when there are no peers. Chip styling * next to SourceChip; the parent hides it when there are no peers. Chip styling
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop. * mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
*
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
* peer's avatar follows their viewport (click again to stop); the followed
* peer gets a ring in their color. The parent renders the "Following…"
* banner — the roster only toggles.
*/ */
const MAX_AVATARS = 5; const MAX_AVATARS = 5;
@ -20,10 +26,16 @@ function sheetLabel(sheetPath?: string): string {
export function PresenceRoster({ export function PresenceRoster({
peers, peers,
activeSheetPath, activeSheetPath,
following,
onFollow,
}: { }: {
peers: PresencePeer[]; peers: PresencePeer[];
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */ /** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
activeSheetPath?: string; activeSheetPath?: string;
/** 0008: the followed client, when a follow is active. */
following?: FollowTarget | null;
/** 0008: toggle following a peer (null = stop). Absent = follow UI off. */
onFollow?: (target: FollowTarget | null) => void;
}) { }) {
if (!peers.length) return null; if (!peers.length) return null;
@ -38,6 +50,16 @@ export function PresenceRoster({
.join(", "); .join(", ");
const shown = ordered.slice(0, MAX_AVATARS); const shown = ordered.slice(0, MAX_AVATARS);
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
const toggleFollow = (p: PresencePeer) => {
if (!onFollow) return;
onFollow(
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
);
};
return ( return (
<span <span
data-testid="presence-roster" data-testid="presence-roster"
@ -45,22 +67,29 @@ export function PresenceRoster({
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20" className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
> >
{shown.map((p) => ( {shown.map((p) => (
<span <button
key={p.user.id} key={p.user.id}
type="button"
data-presence-user={p.user.id} data-presence-user={p.user.id}
data-presence-elsewhere={sameSheet(p) ? undefined : "1"} data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
data-presence-following={isFollowed(p) ? "1" : undefined}
disabled={!followable(p)}
onClick={() => toggleFollow(p)}
title={ title={
sameSheet(p) sameSheet(p)
? p.user.name ? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}` : `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
} }
style={{ backgroundColor: p.user.color }} style={{
backgroundColor: p.user.color,
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${p.user.color}` } : {}),
}}
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${ className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
sameSheet(p) ? "" : "opacity-40" sameSheet(p) ? "cursor-pointer" : "opacity-40"
}`} }`}
> >
{p.user.name.charAt(0).toUpperCase()} {p.user.name.charAt(0).toUpperCase()}
</span> </button>
))} ))}
{peers.length > MAX_AVATARS && ( {peers.length > MAX_AVATARS && (
<span className="ml-1 text-[10px] font-medium text-white/80"> <span className="ml-1 text-[10px] font-medium text-white/80">

View file

@ -56,8 +56,14 @@ import {
import { import {
bindKicadPresence, bindKicadPresence,
hasPresenceBridge, hasPresenceBridge,
type PresenceKicadModule,
type PresenceKicadWindow, type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad"; } from "@/wasm/collab/presence-kicad";
import {
createFollow,
type FollowHandle,
type FollowTarget,
} from "@/wasm/collab/follow-user";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app"; import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import { import {
createComments, createComments,
@ -720,6 +726,8 @@ export function WasmTool({
const driftRef = React.useRef<{ stop(): void } | null>(null); const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null); const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null); const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
// Follow-user (0008): mirror a peer's viewport until local input breaks it.
const followRef = React.useRef<FollowHandle | null>(null);
// Project-wide presence room (0006): joined once per session, survives // Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence. // eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null); const crossAppRef = React.useRef<CrossAppHandle | null>(null);
@ -773,6 +781,8 @@ export function WasmTool({
// eeschema: the sheet THIS client is bound to — the roster dims peers whose // eeschema: the sheet THIS client is bound to — the roster dims peers whose
// skeleton state says they're on a different sheet (collab-presence 0003). // skeleton state says they're on a different sheet (collab-presence 0003).
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>(); const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
// Follow-user (0008): the followed roster client, for the ring + banner.
const [followingTarget, setFollowingTarget] = React.useState<FollowTarget | null>(null);
// Comments (0005): the bound doc's controller + the live viewport transform // Comments (0005): the bound doc's controller + the live viewport transform
// the DOM layer maps world→CSS with. Both rebind with the collab session // the DOM layer maps world→CSS with. Both rebind with the collab session
// (per sheet in eeschema). // (per sheet in eeschema).
@ -922,6 +932,9 @@ export function WasmTool({
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the // pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
// roster shows who is on the SAME sheet (room = sheet). // roster shows who is on the SAME sheet (room = sheet).
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => { const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
followRef.current?.destroy();
followRef.current = null;
setFollowingTarget(null);
presenceBridgeRef.current?.destroy(); presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null; presenceBridgeRef.current = null;
presenceRef.current?.destroy(); presenceRef.current?.destroy();
@ -945,14 +958,29 @@ export function WasmTool({
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without // and the remote VIEW_OVERLAY render. The bridge gate skips tools without
// the exports and wasm builds predating them. // the exports and wasm builds predating them.
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) { if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
// Follow-user (0008): available when the wasm exports FitViewport.
const fitFn = (win.Module as PresenceKicadModule).kicadCollabFitViewport;
if (fitFn) {
const follow = createFollow({
presence,
fit: (cx, cy, halfW, halfH) => fitFn.call(win.Module, cx, cy, halfW, halfH),
ownSheetPath: () => sheetPath,
});
follow.subscribe(setFollowingTarget);
followRef.current = follow;
}
presenceBridgeRef.current = bindKicadPresence({ presenceBridgeRef.current = bindKicadPresence({
mod: win.Module, mod: win.Module,
win: win as unknown as PresenceKicadWindow, win: win as unknown as PresenceKicadWindow,
presence, presence,
// Cross-app selection (0006): the project presence room, if joined. // Cross-app selection (0006): the project presence room, if joined.
crossApp: crossAppRef.current ?? undefined, crossApp: crossAppRef.current ?? undefined,
// Live world↔screen transform for the DOM comment layer (0005). // Live world↔screen transform for the DOM comment layer (0005) +
onViewport: setViewportState, // the follow controller's echo/break detection (0008).
onViewport: (vp) => {
setViewportState(vp);
followRef.current?.noteLocalViewport(vp);
},
}); });
} }
}; };
@ -1212,6 +1240,8 @@ export function WasmTool({
win.removeEventListener("keydown", chromeHotkey, true); win.removeEventListener("keydown", chromeHotkey, true);
commentsRef.current?.destroy(); commentsRef.current?.destroy();
commentsRef.current = null; commentsRef.current = null;
followRef.current?.destroy();
followRef.current = null;
presenceBridgeRef.current?.destroy(); presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null; presenceBridgeRef.current = null;
presenceRef.current?.destroy(); presenceRef.current?.destroy();
@ -1392,6 +1422,27 @@ export function WasmTool({
</div> </div>
)} )}
{/* Follow-user (0008): who we're following + how to stop. Esc also works
because any canvas key input breaks the follow via noteLocalViewport
only when the viewport moves — this banner is the explicit out. */}
{ready && followingTarget && (
<div
data-testid="follow-banner"
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
>
<span>
Following <span className="font-semibold">{followingTarget.name}</span> — move to stop
</span>
<button
type="button"
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
onClick={() => followRef.current?.unfollow()}
>
Stop
</button>
</div>
)}
{/* Top-right overlay row: who else is in this file (awareness roster), {/* Top-right overlay row: who else is in this file (awareness roster),
where this project lives / whether Save persists (chip hidden while where this project lives / whether Save persists (chip hidden while
the UI is hidden), and the Figma-like hide/show-UI toggle — the one the UI is hidden), and the Figma-like hide/show-UI toggle — the one
@ -1402,7 +1453,15 @@ export function WasmTool({
(sourceDescriptor && !chromeHidden)) && ( (sourceDescriptor && !chromeHidden)) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2"> <div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && ( {peers.length > 0 && (
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} /> <PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
)} )}
{sourceDescriptor && !chromeHidden && ( {sourceDescriptor && !chromeHidden && (
<SourceChip descriptor={sourceDescriptor} /> <SourceChip descriptor={sourceDescriptor} />

View file

@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import type { PresencePeer } from "./presence";
import type { ViewportState } from "./presence-kicad";
import { createFollow, type FollowTarget } from "./follow-user";
/**
* Follow-user controller units (collab-presence 0008): apply/republish
* dedupe, echo suppression, break-on-interact, leader-left, sheet gating.
* The presence handle is faked — only clients() + subscribe are used.
*/
const LEADER: FollowTarget = { clientId: 7, userId: "u-lead", name: "lead" };
function peer(over: Partial<PresencePeer> = {}): PresencePeer {
return {
clientId: 7,
user: { id: "u-lead", name: "lead", color: "#ef4444" },
tool: "pcbnew",
cursor: null,
selection: [],
viewport: { cx: 100, cy: 200, halfW: 50, halfH: 40 },
updatedAt: 1,
...over,
} as PresencePeer;
}
function fakePresence(initial: PresencePeer[]) {
let clients = initial;
const subs = new Set<() => void>();
return {
handle: {
clients: () => clients,
subscribe(cb: () => void) {
subs.add(cb);
return () => subs.delete(cb);
},
} as unknown as import("./presence").PresenceHandle,
update(next: PresencePeer[]) {
clients = next;
for (const cb of subs) cb();
},
};
}
/** A local onViewport emit whose world rect equals `rect` on a canvas of
* aspect w:h (contain fit: the wider axis overshoots). */
function echoFor(
rect: { cx: number; cy: number; halfW: number; halfH: number },
w = 1000,
h = 800,
): ViewportState {
const scale = Math.min(w / (2 * rect.halfW), h / (2 * rect.halfH));
return { cx: rect.cx, cy: rect.cy, scale, w, h };
}
describe("createFollow", () => {
it("applies the leader's viewport on follow and on change, deduping republish", () => {
const p = fakePresence([peer()]);
const fit = vi.fn();
const follow = createFollow({ presence: p.handle, fit });
follow.follow(LEADER);
expect(fit).toHaveBeenCalledWith(100, 200, 50, 40);
// Republished but UNCHANGED viewport (e.g. a selection edit) → no re-fit.
p.update([peer()]);
expect(fit).toHaveBeenCalledTimes(1);
p.update([peer({ viewport: { cx: 110, cy: 200, halfW: 50, halfH: 40 } })]);
expect(fit).toHaveBeenCalledTimes(2);
expect(fit).toHaveBeenLastCalledWith(110, 200, 50, 40);
});
it("suppresses the fit's own echo, breaks on a deviating local viewport", () => {
const p = fakePresence([peer()]);
const fit = vi.fn();
const follow = createFollow({ presence: p.handle, fit });
follow.follow(LEADER);
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
follow.noteLocalViewport(echoFor(rect));
expect(follow.following()).toEqual(LEADER);
// User pans far away → follow ends.
follow.noteLocalViewport(echoFor({ ...rect, cx: 500 }));
expect(follow.following()).toBeNull();
});
it("ignores a stale pre-echo local viewport (the fit hasn't landed yet)", () => {
const p = fakePresence([peer()]);
const fit = vi.fn();
const follow = createFollow({ presence: p.handle, fit });
follow.follow(LEADER);
// The canvas still shows the pre-follow region — not a user break.
follow.noteLocalViewport(echoFor({ cx: 9999, cy: 9999, halfW: 10, halfH: 10 }));
expect(follow.following()).toEqual(LEADER);
});
it("a user zoom breaks the follow even at the same center", () => {
const p = fakePresence([peer()]);
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
follow.follow(LEADER);
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
follow.noteLocalViewport(echoFor(rect));
// Zoom-in: both half-extents shrink well past the tolerance.
follow.noteLocalViewport(echoFor({ ...rect, halfW: 25, halfH: 20 }));
expect(follow.following()).toBeNull();
});
it("unfollows when the leader leaves the room", () => {
const p = fakePresence([peer()]);
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
follow.follow(LEADER);
p.update([]);
expect(follow.following()).toBeNull();
});
it("pauses (does not fit) while an eeschema leader is on another sheet", () => {
const p = fakePresence([peer({ sheetPath: "sub.kicad_sch" })]);
const fit = vi.fn();
const follow = createFollow({
presence: p.handle,
fit,
ownSheetPath: () => "demo.kicad_sch",
});
follow.follow(LEADER);
expect(fit).not.toHaveBeenCalled();
// Leader returns to our sheet → fit resumes.
p.update([peer({ sheetPath: "demo.kicad_sch" })]);
expect(fit).toHaveBeenCalledTimes(1);
});
it("notifies subscribers on follow and unfollow", () => {
const p = fakePresence([peer()]);
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
const seen: Array<FollowTarget | null> = [];
follow.subscribe((t) => seen.push(t));
follow.follow(LEADER);
follow.unfollow();
expect(seen).toEqual([LEADER, null]);
});
});

View file

@ -0,0 +1,169 @@
import type { PresenceViewport } from "@pcbjam/shared";
import { clog } from "./debug";
import type { PresenceHandle, PresencePeer } from "./presence";
import type { ViewportState } from "./presence-kicad";
import { viewportRect } from "./presence-kicad";
/**
* Follow-user controller (collab-presence 0008): mirror a peer's viewport
* until the local user interacts.
*
* Follows an awareness CLIENT (a specific tab), not a user — a user's two
* tabs can show different regions and "follow" means watching one screen.
* On every roster change the leader's published world rect (presence wire
* `viewport`) is applied through `kicadCollabFitViewport` (contain: the zoom
* derives from OUR canvas, so different window sizes see the same region).
*
* Break-on-interact needs no extra input hooks: each applied fit remembers
* its target, and every local `onViewport` echo is compared against it —
* a matching echo (epsilon — the rect round-trips through the GAL matrix)
* is our own fit landing; a deviating one means the user panned/zoomed/
* jumped locally, so the follow ends. Same-tool, same-sheet only in v1:
* an eeschema leader on another sheet pauses the fit (rect coordinates are
* per-sheet); cross-tool follow is out of scope.
*
* Follow loops (A follows B, B follows A) are benign: neither side produces
* a non-echo viewport change, so both simply idle.
*/
export interface FollowTarget {
clientId: number;
userId: string;
name: string;
}
export interface FollowHandle {
/** Start following a roster client; replaces any current follow. */
follow(target: FollowTarget): void;
/** Stop following (explicit UI action — Esc / click). */
unfollow(): void;
/** The current follow target, or null. */
following(): FollowTarget | null;
/** Fires with the new target (or null) on every follow state change. */
subscribe(cb: (target: FollowTarget | null) => void): () => void;
/** Feed every local onViewport emit here (echo suppression + break). */
noteLocalViewport(vp: ViewportState): void;
destroy(): void;
}
/** Relative tolerance for "this local viewport is our own fit's echo": the
* rect round-trips world→zoom→world through the GAL matrix, and the canvas
* aspect makes one axis overshoot (contain), so compare center and the
* CONTAINED axis loosely. */
const ECHO_REL_TOLERANCE = 0.02;
function isEcho(vp: ViewportState, target: PresenceViewport): boolean {
const rect = viewportRect(vp);
if (!rect) return true; // degenerate transform — never treat as user input
// Scale-relative center comparison: a pan of >2% of the view is a break.
const tolX = Math.max(rect.halfW, target.halfW) * ECHO_REL_TOLERANCE;
const tolY = Math.max(rect.halfH, target.halfH) * ECHO_REL_TOLERANCE;
if (Math.abs(rect.cx - target.cx) > tolX) return false;
if (Math.abs(rect.cy - target.cy) > tolY) return false;
// Contain-fit: exactly one axis matches the target's half-extent (the other
// overshoots by the aspect difference). A zoom by the user changes BOTH away
// from the target.
const relW = Math.abs(rect.halfW - target.halfW) / target.halfW;
const relH = Math.abs(rect.halfH - target.halfH) / target.halfH;
return relW <= ECHO_REL_TOLERANCE || relH <= ECHO_REL_TOLERANCE;
}
export function createFollow(opts: {
presence: PresenceHandle;
/** Module.kicadCollabFitViewport, bound; absent on older wasm → no-op UI. */
fit: (cx: number, cy: number, halfW: number, halfH: number) => void;
/** The sheet THIS tab shows (eeschema); undefined for single-doc tools. */
ownSheetPath?: () => string | undefined;
}): FollowHandle {
const { presence, fit } = opts;
let target: FollowTarget | null = null;
/** The last rect we asked the canvas to fit — echoes match against this. */
let applied: PresenceViewport | null = null;
/** Grace: ignore break-checks until the first fit's echo arrived, else the
* stale pre-follow viewport emit would instantly end the follow. */
let sawEcho = false;
const subscribers = new Set<(t: FollowTarget | null) => void>();
const notify = () => {
for (const cb of subscribers) cb(target);
};
const leaderState = (): PresencePeer | undefined =>
presence.clients().find((c) => c.clientId === target?.clientId);
const applyLeader = () => {
if (!target) return;
const leader = leaderState();
if (!leader) {
// Leader left the room — follow ends (their awareness state expired).
clog("follow: leader left —", target.name);
stop();
return;
}
// v1: rect coordinates are per-document; pause while the leader is on a
// different sheet than this canvas shows (eeschema warm-pool rooms).
const ownSheet = opts.ownSheetPath?.();
if (ownSheet !== undefined && leader.sheetPath !== undefined && leader.sheetPath !== ownSheet) {
return;
}
const rect = leader.viewport;
if (!rect) return;
if (
applied &&
rect.cx === applied.cx &&
rect.cy === applied.cy &&
rect.halfW === applied.halfW &&
rect.halfH === applied.halfH
) {
return; // leader republished an unchanged viewport (e.g. selection edit)
}
applied = rect;
sawEcho = false;
fit(rect.cx, rect.cy, rect.halfW, rect.halfH);
};
const stop = () => {
if (!target) return;
target = null;
applied = null;
sawEcho = false;
notify();
};
const unsubscribePresence = presence.subscribe(() => applyLeader());
return {
follow(t) {
target = t;
applied = null;
sawEcho = false;
clog("follow: following", t.name, "client", t.clientId);
notify();
applyLeader();
},
unfollow: stop,
following: () => target,
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
noteLocalViewport(vp) {
if (!target || !applied) return;
if (isEcho(vp, applied)) {
sawEcho = true;
return;
}
if (!sawEcho) return; // pre-fit stale emit — the fit hasn't landed yet
clog("follow: local input — stopped following", target.name);
stop();
},
destroy() {
unsubscribePresence();
subscribers.clear();
target = null;
},
};
}

View file

@ -41,6 +41,7 @@ function stubPresence(peers: PresencePeer[] = []) {
}, },
setCursor: vi.fn(), setCursor: vi.fn(),
setSelection: vi.fn(), setSelection: vi.fn(),
setViewport: vi.fn(),
colorOf: vi.fn((id: string) => colorForUser(id)), colorOf: vi.fn((id: string) => colorForUser(id)),
destroy: vi.fn(), destroy: vi.fn(),
firePeers(p) { firePeers(p) {

View file

@ -32,6 +32,9 @@ export interface PresenceKicadModule {
/** 0007: tiebreak release — the local client lost an overlapping hold; /** 0007: tiebreak release — the local client lost an overlapping hold;
* the wasm side cancels an in-flight move and unselects exactly these. */ * the wasm side cancels an in-flight move and unselects exactly these. */
kicadCollabReleaseSelection?(uuidsJson: string, holder: string): void; kicadCollabReleaseSelection?(uuidsJson: string, holder: string): void;
/** 0008: fit a leader's world rect (center + half-extents, IU) into this
* canvas — contain semantics. Absent on older wasm builds. */
kicadCollabFitViewport?(cx: number, cy: number, halfW: number, halfH: number): void;
} }
export interface PresenceKicadWindow { export interface PresenceKicadWindow {
@ -62,6 +65,20 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
const PUSH_THROTTLE_MS = 30; const PUSH_THROTTLE_MS = 30;
// Viewport publishes ride awareness at their own (slower) trailing cadence —
// a smooth pan fires onViewport per frame and peers only need follow-rate.
const VIEWPORT_PUBLISH_MS = 100;
/** The follow-user world rect for a GAL transform: half-extents = half the
* canvas in world units (scale = px per IU). Null while the frame is not up
* (zero-sized canvas / no scale yet). */
export function viewportRect(
vp: ViewportState,
): { cx: number; cy: number; halfW: number; halfH: number } | null {
if (!(vp.scale > 0) || !(vp.w > 0) || !(vp.h > 0)) return null;
return { cx: vp.cx, cy: vp.cy, halfW: vp.w / 2 / vp.scale, halfH: vp.h / 2 / vp.scale };
}
/** /**
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths = * Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
* the selected footprints' `GetPath()` strings), eeschema and older builds a * the selected footprints' `GetPath()` strings), eeschema and older builds a
@ -144,10 +161,37 @@ export function bindKicadPresence(opts: {
presence.setCursor(active ? { x, y } : null); presence.setCursor(active ? { x, y } : null);
}, },
onViewport: (cx, cy, scale, w, h) => { onViewport: (cx, cy, scale, w, h) => {
opts.onViewport?.({ cx, cy, scale, w, h }); const vp = { cx, cy, scale, w, h };
opts.onViewport?.(vp);
scheduleViewportPublish(vp);
}, },
}; };
// Follow-user (0008): publish the visible world rect, trailing-throttled.
let vpTimer: ReturnType<typeof setTimeout> | undefined;
let vpLatest: ViewportState | undefined;
const scheduleViewportPublish = (vp: ViewportState) => {
vpLatest = vp;
if (vpTimer) return;
vpTimer = setTimeout(() => {
vpTimer = undefined;
// Guarded: pre-0008 handle implementations (and their test fakes) don't
// have setViewport yet.
if (vpLatest && typeof presence.setViewport === "function") {
presence.setViewport(viewportRect(vpLatest));
}
}, VIEWPORT_PUBLISH_MS);
};
// Seed the published viewport (pushes only happen on input events after
// this) — same pull the comment layer uses.
try {
const vp = JSON.parse(mod.kicadCollabGetViewport() || "null") as ViewportState | null;
if (vp && vp.w > 0) scheduleViewportPublish(vp);
} catch {
/* frame not up yet — the first input push seeds it */
}
// Seed: the tab may attach with a selection already made (e.g. rebind). The // Seed: the tab may attach with a selection already made (e.g. rebind). The
// Full variant (pcbnew 0006) also carries footprint paths for cross-app. // Full variant (pcbnew 0006) also carries footprint paths for cross-app.
try { try {
@ -241,6 +285,8 @@ export function bindKicadPresence(opts: {
unsubscribeCross?.(); unsubscribeCross?.();
if (pushTimer) clearTimeout(pushTimer); if (pushTimer) clearTimeout(pushTimer);
pushTimer = undefined; pushTimer = undefined;
if (vpTimer) clearTimeout(vpTimer);
vpTimer = undefined;
if (win.kicadCollab) { if (win.kicadCollab) {
delete win.kicadCollab.onSelection; delete win.kicadCollab.onSelection;
delete win.kicadCollab.onCursor; delete win.kicadCollab.onCursor;

View file

@ -5,6 +5,7 @@ import {
presenceStateSchema, presenceStateSchema,
type PresenceState, type PresenceState,
type PresenceUser, type PresenceUser,
type PresenceViewport,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { clog } from "./debug"; import { clog } from "./debug";
@ -45,6 +46,9 @@ export interface PresenceHandle {
setCursor(pos: { x: number; y: number } | null): void; setCursor(pos: { x: number; y: number } | null): void;
/** 0002: publish the local selection's KIID strings. */ /** 0002: publish the local selection's KIID strings. */
setSelection(uuids: string[]): void; setSelection(uuids: string[]): void;
/** 0008: publish the visible world rect (center + half-extents, IU) for
* followers; null while unknown. */
setViewport(rect: PresenceViewport | null): void;
/** A user's current presence color: their published one if they're in the /** A user's current presence color: their published one if they're in the
* room, our claimed one for ourselves, else the palette-hash fallback * room, our claimed one for ourselves, else the palette-hash fallback
* (offline users, e.g. old comment authors). */ * (offline users, e.g. old comment authors). */
@ -277,6 +281,9 @@ export function createPresence(opts: {
setSelection(uuids) { setSelection(uuids) {
patch({ selection: uuids }); patch({ selection: uuids });
}, },
setViewport(rect) {
patch({ viewport: rect });
},
colorOf(userId) { colorOf(userId) {
if (userId === user.id) return user.color; if (userId === user.id) return user.color;
for (const [clientId, raw] of awareness.getStates()) { for (const [clientId, raw] of awareness.getStates()) {