feat(collab): presence P3 — eeschema port (collab-presence 0003)

- eeschema_embind.cpp: presence section (0002 pattern, zero fork changes) —
  wx canvas triggers + SCHEMATIC_LISTENER piggyback → post-settle
  SCH_SELECTION_TOOL emit; throttled cursor; remote VIEW_OVERLAY render
  (SCHEMATIC::ResolveItem, name tags, screen-constant via GAL matrix);
  schCollab{PresenceStart,SetRemote,GetViewport,GetSelection,TestSelectFirst,
  TestClearSelection}; kicad_editor_embind dispatches by active frame.
- sheet-manager: parked rooms carry SKELETON awareness states
  ({user,tool,sheetPath=bound sheet}) via publishSkeletons on switch + late
  warm-up — the bound room's awareness then holds every project peer, so no
  multi-room aggregation; presence.ts publishSkeleton helper.
- PresenceRoster: sheet-aware — peers on another sheet render dimmed with
  'on <sheet>' tooltip; WasmTool un-gates the kicad presence bridge for
  eeschema and threads activeSheetPath.
- tests: presence-eeschema.spec.ts (5 e2e, mirrors pcbnew incl. the px/IU
  band at eeschema's 1e4/mm IU); presence.test.ts +2 (skeleton visibility,
  no ghost cursor after rebind). eeschema-collab/subschema stay green.

Verified live: two tabs on demo.kicad_sch — peer cursor cross + label,
selection box + name tag, roster avatar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-06 17:05:15 +02:00
commit 6bc54de92b
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
9 changed files with 982 additions and 29 deletions

View file

@ -2,7 +2,7 @@
title: "DevBlog 2026 week 26"
description: "Rebasing to KiCad 10.0.4 "
author: Gergő Törcsvári
pubDate: 2026-07-29
pubDate: 2026-06-29
---
# History so far

View file

@ -0,0 +1,320 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* eeschema collab presence C++ bridge e2e (collab-presence 0003).
*
* The eeschema port of presence-pcbnew.spec.ts: selection emit (programmatic +
* real box-select), throttled cursor emit, remote VIEW_OVERLAY render with no
* local-selection leak, and the viewport unit band (px-per-IU through the GAL
* matrix eeschema IU is 1e4/mm, not pcbnew's 1e6, so only the band differs).
* The sheet-scoped awareness layer (skeleton states, rebind on navigation) is
* unit-tested in the standalone (presence.test.ts) this spec covers the wasm
* side on the current sheet.
*/
const WIRE1 = "22222222-0000-0000-0000-000000000001";
const SAMPLE_SCH = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002"))
\t(sheet_instances (path "/" (page "1")))
)
`;
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabPresenceStart(): void;
kicadCollabSetRemote(j: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
kicadCollabTestSelectFirst(): string;
kicadCollabTestClearSelection(): boolean;
};
type PresenceWindow = {
FS: FS;
Module: Mod;
kicadCollab?: Record<string, unknown>;
__selEmits?: string[][];
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
};
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
async function bootAndOpen(page: Page): Promise<void> {
await page.goto("/kicad/eeschema.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSetRemote === "function" &&
typeof m?.kicadCollabTestSelectFirst === "function"
);
},
null,
{ timeout: 90000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 90000 },
);
await page.evaluate(
({ content }) => {
const w = window as unknown as PresenceWindow;
const dir = "/home/kicad/documents";
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
const p = `${dir}/presence.kicad_sch`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: SAMPLE_SCH },
);
await expect
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
.toMatch(/presence/i);
await page.evaluate(() => {
const w = window as unknown as PresenceWindow;
w.__selEmits = [];
w.__cursorEmits = [];
w.kicadCollab = {
...w.kicadCollab,
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
onCursor: (x: number, y: number, active: number) =>
w.__cursorEmits!.push({ x, y, active }),
};
w.Module.kicadCollabPresenceStart();
});
}
test("selection emit: programmatic select/clear reaches onSelection with uuids", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const selectedId = await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
);
expect(selectedId).toBeTruthy();
await expect
.poll(
() =>
page.evaluate(() => {
const w = window as unknown as PresenceWindow;
return w.__selEmits!.at(-1) ?? null;
}),
{ timeout: 10000 },
)
.toEqual([selectedId]);
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabTestClearSelection(),
);
await expect
.poll(
() =>
page.evaluate(() => {
const w = window as unknown as PresenceWindow;
return w.__selEmits!.at(-1) ?? null;
}),
{ timeout: 10000 },
)
.toEqual([]);
expect(hasAbort(testLogger)).toBe(false);
});
test("selection emit: a real canvas box-select drives the wx-layer trigger", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const glId = await page.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find((c) => {
const rect = c.getBoundingClientRect();
return window.getComputedStyle(c).display !== "none" && rect.width > 0;
});
return visible?.id ?? null;
});
expect(glId).toBeTruthy();
const box = await page.locator(`#${glId}`).boundingBox();
expect(box).toBeTruthy();
await page.mouse.move(box!.x + box!.width * 0.1, box!.y + box!.height * 0.1);
await page.mouse.down();
await page.mouse.move(box!.x + box!.width * 0.9, box!.y + box!.height * 0.9, { steps: 8 });
await page.mouse.up();
await expect
.poll(
() =>
page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabGetSelection(),
).length,
),
{ timeout: 10000, message: "box-select never selected anything" },
)
.toBeGreaterThan(0);
await expect
.poll(
() =>
page.evaluate(() => {
const w = window as unknown as PresenceWindow;
return w.__selEmits!.some((s) => s.length > 0);
}),
{ timeout: 10000, message: "selection happened but onSelection never emitted" },
)
.toBe(true);
expect(hasAbort(testLogger)).toBe(false);
});
test("cursor emit: mouse motion over the canvas produces throttled world coords", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const canvas = page.locator("#canvas");
const box = await canvas.boundingBox();
expect(box).toBeTruthy();
const cx = box!.x + box!.width / 2;
const cy = box!.y + box!.height / 2;
for (let i = 0; i < 30; i++) {
await page.mouse.move(cx - 150 + i * 10, cy, { steps: 1 });
}
const emits = await page.evaluate(
() => (window as unknown as PresenceWindow).__cursorEmits!,
);
expect(emits.length).toBeGreaterThan(0);
expect(emits.length).toBeLessThan(25);
const active = emits.filter((e) => e.active === 1);
expect(active.length).toBeGreaterThan(0);
for (const e of active) {
expect(Math.abs(e.x)).toBeLessThan(1e8);
expect(Math.abs(e.y)).toBeLessThan(1e8);
}
expect(hasAbort(testLogger)).toBe(false);
});
test("remote render paints the overlay without touching local selection", async ({
page,
testLogger,
}) => {
await bootAndOpen(page);
const canvas = page.locator("#canvas");
const before = await canvas.screenshot();
await page.evaluate(
({ wire }) => {
const w = window as unknown as PresenceWindow;
w.Module.kicadCollabSetRemote(
JSON.stringify({
peers: [
{
id: "bob",
name: "bob",
color: "#ef4444",
// eeschema IU = 1e4/mm; park the cursor around (90,90) mm.
cursor: { x: 90e4, y: 90e4 },
selection: [wire],
},
],
}),
);
},
{ wire: WIRE1 },
);
await expect
.poll(
async () => {
const after = await canvas.screenshot();
return !after.equals(before);
},
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
const localSel = await page.evaluate(() =>
JSON.parse(
(window as unknown as PresenceWindow).Module.kicadCollabGetSelection(),
),
);
expect(localSel).toEqual([]);
await page.evaluate(() =>
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
JSON.stringify({ peers: [] }),
),
);
await expect
.poll(
async () => {
const after = await canvas.screenshot();
return after.equals(before);
},
{ timeout: 15000, intervals: [500] },
)
.toBe(true);
expect(hasAbort(testLogger)).toBe(false);
});
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
await bootAndOpen(page);
const vp = await page.evaluate(() =>
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
);
expect(vp.w).toBeGreaterThan(0);
expect(vp.h).toBeGreaterThan(0);
// px per IU through the GAL matrix. eeschema IU = 1e4/mm (100× coarser than
// pcbnew), so a framed A4 sheet is O(1e-3) px/IU — assert the band + a sane
// world width, same guard as pcbnew for the GetScale()-is-zoom bug.
expect(vp.scale).toBeGreaterThan(0);
expect(vp.scale).toBeLessThan(1);
const worldWidthMm = vp.w / vp.scale / 1e4;
expect(worldWidthMm).toBeGreaterThan(50);
expect(worldWidthMm).toBeLessThan(3000);
expect(vp.cx).toBeGreaterThan(0);
expect(vp.cx).toBeLessThan(400e4);
expect(vp.cy).toBeGreaterThan(0);
expect(vp.cy).toBeLessThan(400e4);
expect(hasAbort(testLogger)).toBe(false);
});

View file

@ -29,7 +29,16 @@
#include <richio.h>
#include <lib_symbol.h>
#include <tools/sch_selection.h>
#include <tools/sch_selection_tool.h>
#include <sch_commit.h>
#include <sch_draw_panel.h>
#include <geometry/eda_angle.h>
#include <math/util.h>
#include <tool/tool_manager.h>
#include <view/view.h>
#include <view/view_overlay.h>
#include <chrono>
#include <wx/event.h>
#include <sch_item.h>
#include <sch_line.h>
#include <sch_junction.h>
@ -599,6 +608,12 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
// ChangeSource: the native SCHEMATIC_LISTENER is just a trigger — the actual change set comes
// from the post-settle snapshot diff above. Skipped while applying a remote delta (no echo);
// doApply rebaselines instead.
//
// Presence (collab-presence 0003): schematic changes often change the selection
// too (delete, paste) with no closing canvas event — the trigger below also
// piggybacks a selection re-check. Defined in the presence section further down.
void schedulePresenceSelCheck();
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
{
public:
@ -639,6 +654,7 @@ private:
noteDirty( item );
scheduleFlush();
schedulePresenceSelCheck();
}
};
@ -663,6 +679,272 @@ SCHEMATIC* ensureBridge()
return &sch;
}
// ───────────────────────── collab presence (collab-presence 0003) ─────────────────────────
//
// eeschema port of pcbnew's presence layer (0002 — see pcbnew_embind.cpp for the full
// design rationale): emit THIS tab's selection + cursor to JS, render REMOTE peers'
// cursors + selection outlines into a per-user-colored VIEW_OVERLAY. Zero kicad-fork
// changes: wx-layer Bind() triggers on the GAL canvas + the COLLAB_LISTENER piggyback,
// selection read post-settle from SCH_SELECTION_TOOL, KIIDs resolved via
// SCHEMATIC::ResolveItem. Rooms are per-sheet (warm pool), so peers publishing
// cursor/selection in the bound room are BY CONSTRUCTION on this same sheet file —
// no sheet filtering is needed here; the JS side rebinds the whole presence layer on
// sheet navigation (onSheetChanged) and clears the overlay in between.
namespace presence {
struct PEER
{
std::string name;
KIGFX::COLOR4D color;
bool hasCursor = false;
VECTOR2D cursor; // world coords (IU)
std::vector<KIID> selection;
};
std::vector<PEER> g_peers;
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
bool g_started = false;
bool g_redrawScheduled = false;
bool g_selCheckScheduled = false;
std::string g_lastSelectionJson; // dedupe: emit only when the uuid set changed
long long g_lastCursorEmitMs = 0;
double g_lastVpScale = 0.0;
VECTOR2D g_lastVpCenter;
long long nowMs()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch() )
.count();
}
KIGFX::COLOR4D parseColor( const std::string& aHex )
{
if( aHex.size() == 7 && aHex[0] == '#' )
{
long v = strtol( aHex.c_str() + 1, nullptr, 16 );
return KIGFX::COLOR4D( ( ( v >> 16 ) & 0xff ) / 255.0, ( ( v >> 8 ) & 0xff ) / 255.0,
( v & 0xff ) / 255.0, 0.9 );
}
return KIGFX::COLOR4D( 0.23, 0.51, 0.96, 0.9 ); // palette blue fallback
}
json selectionUuids( SCH_EDIT_FRAME* aFrame )
{
json uuids = json::array();
SCH_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<SCH_SELECTION_TOOL>();
if( !selTool )
return uuids;
for( EDA_ITEM* item : selTool->GetSelection() )
uuids.push_back( toUtf8( item->m_Uuid.AsString() ) );
return uuids;
}
// Post-settle selection emit (see pcbnew): dedupe against the last emitted set.
void checkSelection()
{
g_selCheckScheduled = false;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
std::string s = selectionUuids( fr ).dump();
if( s == g_lastSelectionJson )
return;
g_lastSelectionJson = s;
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onSelection )
window.kicadCollab.onSelection( UTF8ToString( $0 ) );
}, s.c_str() );
}
// Repaint the remote-peers overlay (CallAfter + COROUTINE — MakeOverlay's view->Add
// and the items' virtual ViewBBox() need the fiber stack).
void redrawOverlay()
{
g_redrawScheduled = false;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
if( !g_overlay )
g_overlay = view->MakeOverlay();
g_overlay->Clear();
// Screen-constant sizing via the GAL matrix (GetScale() is the zoom, not px/IU).
double px = view->ToWorld( 1.0 );
for( const PEER& peer : g_peers )
{
g_overlay->SetIsFill( false );
g_overlay->SetIsStroke( true );
g_overlay->SetStrokeColor( peer.color );
g_overlay->SetLineWidth( 2.5 * px );
for( const KIID& id : peer.selection )
{
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
if( !item )
continue; // not in this schematic (yet) — skip silently
BOX2I bb = item->ViewBBox();
bb.Inflate( KiROUND( 4 * px ) );
g_overlay->Rectangle( bb.GetOrigin(), bb.GetEnd() );
// Who selected it — name tag just above the box's top-left corner.
if( !peer.name.empty() )
{
g_overlay->SetGlyphSize( VECTOR2I( KiROUND( 9 * px ), KiROUND( 9 * px ) ) );
g_overlay->BitmapText( wxString::FromUTF8( peer.name.c_str() ),
VECTOR2I( bb.GetOrigin().x,
KiROUND( bb.GetOrigin().y - 8 * px ) ),
ANGLE_0 );
}
}
if( peer.hasCursor )
{
g_overlay->SetLineWidth( 2.0 * px );
g_overlay->Cross( peer.cursor, KiROUND( 7 * px ) );
if( !peer.name.empty() )
{
g_overlay->SetGlyphSize( VECTOR2I( KiROUND( 10 * px ), KiROUND( 10 * px ) ) );
g_overlay->BitmapText( wxString::FromUTF8( peer.name.c_str() ),
VECTOR2I( KiROUND( peer.cursor.x + 10 * px ),
KiROUND( peer.cursor.y + 16 * px ) ),
ANGLE_0 );
}
}
}
view->Update( g_overlay.get() );
fr->GetCanvas()->ForceRefresh();
}
void scheduleRedraw()
{
if( g_redrawScheduled )
return;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
g_redrawScheduled = true;
fr->CallAfter( []() {
COROUTINE<int, int> cor( []( int ) -> int
{
redrawOverlay();
return 0;
} );
cor.Call( 0 );
} );
}
// Viewport push/pull (world↔screen mapping for the DOM layers, 0005).
void emitViewportIfChanged()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
double scale = view->GetScale(); // zoom — cheap change detector only
VECTOR2D c = view->GetCenter();
if( scale == g_lastVpScale && c == g_lastVpCenter )
return;
g_lastVpScale = scale;
g_lastVpCenter = c;
const VECTOR2I& sz = view->GetScreenPixelSize();
// px per IU via the GAL matrix — GetScale() is the zoom, not px/IU.
double pxPerIu = view->ToScreen( 1.0 );
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onViewport )
window.kicadCollab.onViewport( $0, $1, $2, $3, $4 );
}, c.x, c.y, pxPerIu, sz.x, sz.y );
if( !g_peers.empty() )
scheduleRedraw();
}
void emitCursor( double aX, double aY, bool aActive )
{
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onCursor )
window.kicadCollab.onCursor( $0, $1, $2 );
}, aX, aY, aActive ? 1 : 0 );
}
void onMotion( wxMouseEvent& aEvt )
{
aEvt.Skip();
long long now = nowMs();
if( now - g_lastCursorEmitMs < 50 ) // ≤20 emits/s, event-driven (no timers)
return;
g_lastCursorEmitMs = now;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
wxPoint p = aEvt.GetPosition();
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
emitCursor( world.x, world.y, true );
emitViewportIfChanged(); // catches drag-pan while moving
}
void onLeave( wxMouseEvent& aEvt )
{
aEvt.Skip();
emitCursor( 0, 0, false );
}
} // namespace presence
void schedulePresenceSelCheck()
{
if( presence::g_selCheckScheduled )
return;
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
presence::g_selCheckScheduled = true;
fr->CallAfter( []() { presence::checkSelection(); } );
}
} // namespace
@ -1234,6 +1516,160 @@ extern "C" void kicadCollabOnSave( const char* aPath )
}
#endif // !KICAD_MERGED_EMBIND
// ── presence entry points (collab-presence 0003 — eeschema port of the 0002 set) ────────────
// Install the presence input hooks on the GAL canvas (idempotent). The canvas is the
// same window across sheet navigation, so one install serves the whole session.
void schCollabPresenceStart()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr || presence::g_started )
return;
presence::g_started = true;
wxWindow* canvas = fr->GetCanvas();
canvas->Bind( wxEVT_MOTION, []( wxMouseEvent& e ) { presence::onMotion( e ); } );
canvas->Bind( wxEVT_LEAVE_WINDOW, []( wxMouseEvent& e ) { presence::onLeave( e ); } );
auto selAndViewport = []( wxEvent& e )
{
e.Skip();
schedulePresenceSelCheck();
if( SCH_EDIT_FRAME* f = schFrame() )
f->CallAfter( []() { presence::emitViewportIfChanged(); } );
};
canvas->Bind( wxEVT_LEFT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
canvas->Bind( wxEVT_RIGHT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
canvas->Bind( wxEVT_KEY_UP, [selAndViewport]( wxKeyEvent& e ) { selAndViewport( e ); } );
canvas->Bind( wxEVT_MOUSEWHEEL, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
}
// JS → C++: full remote-peers snapshot (same wire as pcbnew's kicadCollabSetRemote).
// Rooms are per-sheet, so the JS rebind pushes a fresh (or empty) snapshot on every
// sheet switch — peers here are always this sheet's.
void schCollabSetRemote( std::string aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
std::vector<presence::PEER> peers;
for( const json& p : j.value( "peers", json::array() ) )
{
presence::PEER peer;
peer.name = p.value( "name", "" );
peer.color = presence::parseColor( p.value( "color", "" ) );
if( p.contains( "cursor" ) && p["cursor"].is_object() )
{
peer.hasCursor = true;
peer.cursor = VECTOR2D( p["cursor"].value( "x", 0.0 ), p["cursor"].value( "y", 0.0 ) );
}
for( const json& u : p.value( "selection", json::array() ) )
{
if( u.is_string() )
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
peers.push_back( std::move( peer ) );
}
presence::g_peers = std::move( peers );
schCollabPresenceStart();
presence::scheduleRedraw();
}
// 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).
std::string schCollabGetViewport()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "";
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
VECTOR2D c = view->GetCenter();
const VECTOR2I& sz = view->GetScreenPixelSize();
return json{ { "cx", c.x }, { "cy", c.y }, { "scale", view->ToScreen( 1.0 ) },
{ "w", sz.x }, { "h", sz.y } }.dump();
}
// JS pull of the CURRENT selection's uuids (presence seed + e2e no-leak probe).
std::string schCollabGetSelection()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "[]";
return presence::selectionUuids( fr ).dump();
}
// Test helper: REALLY select the current sheet's first item through the selection
// tool, then run the presence check (programmatic selects close no canvas event).
std::string schCollabTestSelectFirst()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "";
SCH_SCREEN* screen = currentScreen( fr );
if( !screen )
return "";
SCH_ITEM* target = nullptr;
for( SCH_ITEM* item : screen->Items() )
{
target = item;
break;
}
if( !target )
return "";
fr->CallAfter( [fr, target]() {
if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>() )
{
st->AddItemToSel( target );
schedulePresenceSelCheck();
}
} );
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper: clear the selection through the tool + run the presence check.
bool schCollabTestClearSelection()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return false;
fr->CallAfter( [fr]() {
if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>() )
{
st->ClearSelection();
schedulePresenceSelCheck();
}
} );
return true;
}
// Merged-image dispatch probe (kicad_editor_embind.cpp): is the active top window the
// schematic editor? Counterpart of pcbnew_embind.cpp's pcbEditorActive().
bool schEditorActive()
@ -1296,6 +1732,13 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// ysync-review repro hooks shared with pcbnew (dispatched when merged).
function("kicadCollabTestRemoveItem", &schCollabTestRemoveItem);
function("kicadCollabTestRotateItem", &schCollabTestRotateItem);
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
function("kicadCollabPresenceStart", &schCollabPresenceStart);
function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
#endif // !KICAD_MERGED_EMBIND
}
#endif

View file

@ -62,6 +62,13 @@ std::string schCollabTestMoveFirst( int aDx, int aDy );
std::string schCollabGetPos( std::string aId );
bool schCollabTestRemoveItem( std::string aId );
bool schCollabTestRotateItem( std::string aId, double aDeg );
// Presence (collab-presence 0003 — eeschema counterparts of the 0002 set).
void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
std::string schCollabGetViewport();
std::string schCollabGetSelection();
std::string schCollabTestSelectFirst();
bool schCollabTestClearSelection();
// Programmatically open a project file in the running editor frame, without UI
@ -140,39 +147,36 @@ static bool collabTestRotateItem( std::string aId, double aDeg )
: schCollabTestRotateItem( aId, aDeg );
}
// Presence shims (collab-presence 0002): pcb-only for now — the sch frame no-ops /
// returns empty until 0003 lands schCollab* counterparts, matching how the JS side
// gates presence on the active tool.
// Presence shims (collab-presence 0002 pcbnew / 0003 eeschema): route to the live
// editor's implementation, same pattern as the collab bridge shims above.
static void collabPresenceStart()
{
if( pcbEditorActive() )
pcbCollabPresenceStart();
pcbEditorActive() ? pcbCollabPresenceStart() : schCollabPresenceStart();
}
static void collabSetRemote( std::string aJson )
{
if( pcbEditorActive() )
pcbCollabSetRemote( aJson );
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
}
static std::string collabGetViewport()
{
return pcbEditorActive() ? pcbCollabGetViewport() : std::string();
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
}
static std::string collabGetSelection()
{
return pcbEditorActive() ? pcbCollabGetSelection() : std::string( "[]" );
return pcbEditorActive() ? pcbCollabGetSelection() : schCollabGetSelection();
}
static std::string collabTestSelectFirst()
{
return pcbEditorActive() ? pcbCollabTestSelectFirst() : std::string();
return pcbEditorActive() ? pcbCollabTestSelectFirst() : schCollabTestSelectFirst();
}
static bool collabTestClearSelection()
{
return pcbEditorActive() ? pcbCollabTestClearSelection() : false;
return pcbEditorActive() ? pcbCollabTestClearSelection() : schCollabTestClearSelection();
}

View file

@ -1,18 +1,43 @@
import type { PresencePeer } from "@/wasm/collab/presence";
/**
* "Who else is in this file" (collab-presence 0001): a compact facepile of the
* room's OTHER users, one colored initial-avatar per person, fed by the collab
* session's awareness. Rendered in the editor's top-right overlay stack 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.
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
* the room's OTHER users, one colored initial-avatar per person, fed by the
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
* 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
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
*/
const MAX_AVATARS = 5;
export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
function sheetLabel(sheetPath?: string): string {
if (!sheetPath) return "";
const base = sheetPath.split("/").pop() ?? sheetPath;
return base.replace(/\.kicad_sch$/, "");
}
export function PresenceRoster({
peers,
activeSheetPath,
}: {
peers: PresencePeer[];
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
activeSheetPath?: string;
}) {
if (!peers.length) return null;
const names = peers.map((p) => p.user.name).join(", ");
const shown = peers.slice(0, MAX_AVATARS);
const sameSheet = (p: PresencePeer) =>
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
const names = ordered
.map((p) =>
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
)
.join(", ");
const shown = ordered.slice(0, MAX_AVATARS);
return (
<span
data-testid="presence-roster"
@ -23,9 +48,16 @@ export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
<span
key={p.user.id}
data-presence-user={p.user.id}
title={p.user.name}
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
title={
sameSheet(p)
? p.user.name
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
}
style={{ backgroundColor: 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"
}`}
>
{p.user.name.charAt(0).toUpperCase()}
</span>

View file

@ -444,6 +444,9 @@ async function startSheetCollab(
provider: yjsProviderConfig(),
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
onActiveChange: opts.onActiveChange,
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
// any sheet's roster shows the whole schematic's crew (0003).
presenceUser: presenceUser(),
log: opts.log,
initial:
opts.session && opts.targetPath
@ -627,6 +630,9 @@ export function WasmTool({
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
// provider has no awareness (kind "none"), or nobody else is here.
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
// 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).
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
const append = React.useCallback(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
@ -772,10 +778,11 @@ export function WasmTool({
presenceRef.current = presence;
presence.subscribe(setPeers);
setPeers(presence.peers());
// Canvas presence (0002): cursor + selection emit and the remote
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
// bridge gate also skips wasm builds predating the presence exports.
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
setActiveSheetPath(sheetPath);
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
// the exports and wasm builds predating them.
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
presenceBridgeRef.current = bindKicadPresence({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
@ -1099,7 +1106,9 @@ export function WasmTool({
where this project lives / whether Save persists. */}
{ready && (peers.length > 0 || sourceDescriptor) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && <PresenceRoster peers={peers} />}
{peers.length > 0 && (
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
)}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
</div>
)}

View file

@ -3,7 +3,7 @@ import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { connectAwarenessBroadcast } from "./awareness-bc";
import { createPresence, type PresenceHandle } from "./presence";
import { createPresence, publishSkeleton, type PresenceHandle } from "./presence";
/**
* Presence unit tests (collab-presence 0001): two Awareness instances relayed
@ -107,6 +107,86 @@ describe("presence over the BroadcastChannel awareness relay", () => {
expect(a.presence.peers()).toEqual([]);
});
it("skeleton states mark parked-room users with their real sheet (0003)", async () => {
// Two per-sheet rooms; alice is BOUND to root and PARKED in sub, bob is
// bound to sub. Bob's roster (sub room) must show alice as being on root,
// with no cursor/selection.
const rootCh = `presence-test-${channelSeq++}`;
const subCh = `presence-test-${channelSeq++}`;
const aliceRoot = client(rootCh);
const aliceSub = client(subCh);
const bobSub = client(subCh);
aliceRoot.presence = createPresence({
awareness: aliceRoot.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
publishSkeleton(aliceSub.awareness, user("alice"), "eeschema", "root.kicad_sch");
bobSub.presence = createPresence({
awareness: bobSub.awareness,
user: user("bob"),
tool: "eeschema",
sheetPath: "sub/child.kicad_sch",
});
await settle();
const aliceSeenByBob = bobSub.presence.peers();
expect(aliceSeenByBob.map((p) => p.user.id)).toEqual(["alice"]);
expect(aliceSeenByBob[0]!.sheetPath).toBe("root.kicad_sch");
expect(aliceSeenByBob[0]!.cursor).toBeNull();
expect(aliceSeenByBob[0]!.selection).toEqual([]);
// Alice navigates into sub: full presence rebinds there (overwriting the
// skeleton) — bob now sees her on HIS sheet, cursor live again.
aliceSub.presence = createPresence({
awareness: aliceSub.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "sub/child.kicad_sch",
});
aliceSub.presence.setCursor({ x: 1, y: 2 });
await settle();
const after = bobSub.presence.peers();
expect(after[0]!.sheetPath).toBe("sub/child.kicad_sch");
expect(after[0]!.cursor).toEqual({ x: 1, y: 2 });
});
it("rebind away leaves no ghost cursor in the old room", async () => {
// Alice bound in room1 with a live cursor; she navigates away: presence
// destroy + skeleton must leave sheetPath pointing elsewhere, cursor null.
const ch = `presence-test-${channelSeq++}`;
const alice = client(ch);
const bob = client(ch);
alice.presence = createPresence({
awareness: alice.awareness,
user: user("alice"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
alice.presence.setCursor({ x: 9, y: 9 });
bob.presence = createPresence({
awareness: bob.awareness,
user: user("bob"),
tool: "eeschema",
sheetPath: "root.kicad_sch",
});
await settle();
expect(bob.presence.peers()[0]!.cursor).toEqual({ x: 9, y: 9 });
alice.presence.destroy();
alice.presence = undefined;
publishSkeleton(alice.awareness, user("alice"), "eeschema", "sub/child.kicad_sch");
await settle();
const ghost = bob.presence.peers();
expect(ghost.map((p) => p.user.id)).toEqual(["alice"]);
expect(ghost[0]!.cursor).toBeNull();
expect(ghost[0]!.sheetPath).toBe("sub/child.kicad_sch");
});
it("subscribe fires on change and setters keep sibling fields intact", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);

View file

@ -40,6 +40,30 @@ export interface PresenceHandle {
destroy(): void;
}
/**
* Publish a SKELETON presence state into a room the user is connected to but
* not looking at (eeschema warm pool, collab-presence 0003): identity + which
* sheet they are actually on, no cursor/selection. Any sheet's roster can then
* answer "who is in this schematic, and where". The bound room's full state is
* owned by `createPresence` (which overwrites the skeleton on rebind).
*/
export function publishSkeleton(
awareness: Awareness,
user: PresenceUser,
tool: string,
sheetPath: string,
): void {
const state: PresenceState = {
user,
tool,
sheetPath,
cursor: null,
selection: [],
updatedAt: Date.now(),
};
awareness.setLocalState(state);
}
export function createPresence(opts: {
awareness: Awareness;
user: PresenceUser;

View file

@ -1,6 +1,13 @@
import type * as Y from "yjs";
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
import {
collabRoomId,
fileToDoc,
syncLayoutToY,
type KicadDoc,
type PresenceUser,
} from "@pcbjam/shared";
import { connectKicadDoc, type KicadDocSession } from "./index";
import { publishSkeleton } from "./presence";
import {
bindKicadCollab,
moduleItemsBridge,
@ -79,6 +86,15 @@ export interface SheetManagerOptions {
* (re)start drift detection on the now-active doc.
*/
onActiveChange?: (active: ActiveSheet | null) => void;
/**
* Presence identity (collab-presence 0003). When set, every PARKED room in the
* warm pool carries a skeleton awareness state ({user, tool, sheetPath: the
* sheet the user is ACTUALLY on}) so any sheet's roster can answer "who is in
* this schematic, and where". The BOUND room's full presence (cursor/selection)
* is owned by the host via onActiveChange createPresence, which overwrites
* the skeleton on rebind.
*/
presenceUser?: PresenceUser;
log: (m: string) => void;
/**
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
@ -132,6 +148,19 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
});
}
// Skeleton presence for every PARKED room (0003): mark this user as "in this
// schematic, on `activePath`". The bound room is skipped — its full state is
// published by the host's presence handle (rebound via onActiveChange).
function publishSkeletons(): void {
const user = opts.presenceUser;
if (!user || !activePath) return;
for (const [path, room] of rooms) {
if (path === activePath) continue;
const awareness = room.session.provider.awareness;
if (awareness) publishSkeleton(awareness, user, "eeschema", activePath);
}
}
async function ensureRoom(sheetPath: string): Promise<Room> {
const existing = rooms.get(sheetPath);
if (existing) return existing;
@ -152,6 +181,14 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
};
rooms.set(sheetPath, room);
log(`[sheet] warm room connected: ${sheetPath}`);
// A room warmed after the first bind starts parked — give it a skeleton
// right away so its roster shows this user without waiting for a switch.
if (activePath && sheetPath !== activePath && opts.presenceUser) {
const awareness = session.provider.awareness;
if (awareness) {
publishSkeleton(awareness, opts.presenceUser, "eeschema", activePath);
}
}
return room;
})();
@ -219,6 +256,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
activePath = sheetPath;
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
// AFTER the host rebound its full presence to the new room: refresh every
// parked room's skeleton to point at the new sheet (incl. the old active
// room, whose full state the host just cleared).
publishSkeletons();
}
function switchTo(sheetPath: string): Promise<void> {