From 6bc54de92be471d1028e78bd17c0e7c809f4297d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 6 Jul 2026 17:05:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(collab):=20presence=20P3=20=E2=80=94=20ees?= =?UTF-8?q?chema=20port=20(collab-presence=200003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ' 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 Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq --- site/src/content/blog/devblog-2026-w26.mdx | 2 +- tests/kicad/presence-eeschema.spec.ts | 320 +++++++++++++ wasm/bindings/eeschema_embind.cpp | 443 ++++++++++++++++++ wasm/bindings/kicad_editor_embind.cpp | 26 +- .../src/components/PresenceRoster.tsx | 52 +- web/standalone/src/components/WasmTool.tsx | 19 +- .../src/wasm/collab/presence.test.ts | 82 +++- web/standalone/src/wasm/collab/presence.ts | 24 + .../src/wasm/collab/sheet-manager.ts | 43 +- 9 files changed, 982 insertions(+), 29 deletions(-) create mode 100644 tests/kicad/presence-eeschema.spec.ts diff --git a/site/src/content/blog/devblog-2026-w26.mdx b/site/src/content/blog/devblog-2026-w26.mdx index 65dbcb0..0da4ddf 100644 --- a/site/src/content/blog/devblog-2026-w26.mdx +++ b/site/src/content/blog/devblog-2026-w26.mdx @@ -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 diff --git a/tests/kicad/presence-eeschema.spec.ts b/tests/kicad/presence-eeschema.spec.ts new file mode 100644 index 0000000..bb56f36 --- /dev/null +++ b/tests/kicad/presence-eeschema.spec.ts @@ -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; + __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 { + 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 }).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); +}); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index be383cd..f98fd39 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -29,7 +29,16 @@ #include #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -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 selection; +}; + +std::vector g_peers; +std::shared_ptr 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::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(); + + 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 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 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().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() ) + { + 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() ) + { + 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 diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 072cba9..ac0f1c6 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -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(); } diff --git a/web/standalone/src/components/PresenceRoster.tsx b/web/standalone/src/components/PresenceRoster.tsx index 899fcb1..4ed7780 100644 --- a/web/standalone/src/components/PresenceRoster.tsx +++ b/web/standalone/src/components/PresenceRoster.tsx @@ -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 ( {p.user.name.charAt(0).toUpperCase()} diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index f297629..a23b8a6 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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([]); + // 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(); 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) && (
- {peers.length > 0 && } + {peers.length > 0 && ( + + )} {sourceDescriptor && }
)} diff --git a/web/standalone/src/wasm/collab/presence.test.ts b/web/standalone/src/wasm/collab/presence.test.ts index c8740e7..b308261 100644 --- a/web/standalone/src/wasm/collab/presence.test.ts +++ b/web/standalone/src/wasm/collab/presence.test.ts @@ -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); diff --git a/web/standalone/src/wasm/collab/presence.ts b/web/standalone/src/wasm/collab/presence.ts index 5f24e01..6c056e0 100644 --- a/web/standalone/src/wasm/collab/presence.ts +++ b/web/standalone/src/wasm/collab/presence.ts @@ -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; diff --git a/web/standalone/src/wasm/collab/sheet-manager.ts b/web/standalone/src/wasm/collab/sheet-manager.ts index 1b73d15..2fb48c8 100644 --- a/web/standalone/src/wasm/collab/sheet-manager.ts +++ b/web/standalone/src/wasm/collab/sheet-manager.ts @@ -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 { 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 {