feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)

Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.

- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
  full PresenceState at selection rate (cursor always null); peers() = other-
  TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
  xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
  cross peers appended to the kicadCollabSetRemote snapshot as
  {id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
  and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
  ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
  room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
  patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
  TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
  merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
  13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
  partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
  (the whole-window #canvas compare flaked on the auto-dismissing version
  infobar — also fixes the long-known presence-eeschema restore flake);
  cross-app + presence-kicad vitest suites

Spec: docs/features/collab-presence/0006 (closed repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
Gergő Törcsvári 2026-07-07 19:30:18 +02:00
commit 3186986a0a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 1276 additions and 48 deletions

View file

@ -701,6 +701,10 @@ struct PEER
bool hasCursor = false;
VECTOR2D cursor; // world coords (IU)
std::vector<KIID> selection;
// Cross-app selection (0006): SYMBOL uuids derived from a pcbnew peer's
// footprint paths (the TS side strips the KIID_PATH down to its tail).
// Ghost-rendered when the symbol resolves onto the CURRENT sheet.
std::vector<KIID> xsel;
};
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
@ -839,6 +843,29 @@ void redrawOverlay()
g_style );
}
// Cross-app ghosts (0006): a pcbnew peer's selection, as symbol uuids.
// Unlike same-room selections (per-sheet rooms scope those by
// construction) these arrive project-wide, so only draw items that
// resolve onto the sheet THIS canvas is showing — a bbox from another
// sheet's screen would land at meaningless coordinates here.
if( !peer.xsel.empty() )
{
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
for( const KIID& id : peer.xsel )
{
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
if( !item || path.LastScreen() != fr->GetScreen() )
continue;
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
item->ViewBBox(), peer.name, color, px,
ghost );
}
}
if( peer.hasCursor )
pcbjam_presence::drawCursor( g_overlay.get(), g_textOverlay.get(), peer.cursor,
peer.name, color, px, g_style );
@ -1596,6 +1623,13 @@ void schCollabSetRemote( std::string aJson )
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
// Cross-app selection (0006): symbol uuids from a pcbnew peer.
for( const json& u : p.value( "xsel", json::array() ) )
{
if( u.is_string() )
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
peers.push_back( std::move( peer ) );
}
@ -1776,6 +1810,48 @@ std::string schCollabGetSelection()
return presence::selectionUuids( fr ).dump();
}
// JS pull of the current selection in the 0006 payload shape. eeschema has no
// footprint paths — the uuids ARE the symbol uuids — but the export keeps the
// merged image's kicadCollabGetSelectionFull contract uniform across editors.
std::string schCollabGetSelectionFull()
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return "{\"uuids\":[],\"fpPaths\":[]}";
json payload;
payload["uuids"] = presence::selectionUuids( fr );
payload["fpPaths"] = json::array();
return payload.dump();
}
// Test probe (0006): the schematic-item uuids the current peers' cross-app
// selections resolve to ON THE CURRENT SHEET (mirrors the redraw's gate).
std::string schCollabTestGetCrossMapped()
{
json arr = json::array();
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return arr.dump();
for( const presence::PEER& peer : presence::g_peers )
{
for( const KIID& id : peer.xsel )
{
SCH_SHEET_PATH path;
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
if( item && path.LastScreen() == fr->GetScreen() )
arr.push_back( toUtf8( item->m_Uuid.AsString() ) );
}
}
return arr.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()
@ -1812,6 +1888,48 @@ std::string schCollabTestSelectFirst()
return toUtf8( target->m_Uuid.AsString() );
}
// Test helper (0006): REALLY select the first SYMBOL on the current sheet —
// the deterministic cross-app subject (TestSelectFirst may pick a wire, which
// legitimately maps to nothing in pcbnew). Returns the uuid, "" without one.
std::string schCollabTestSelectComponent()
{
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().OfType( SCH_SYMBOL_T ) )
{
// Power symbols (PWR_FLAG, GND, …) legitimately have no footprint —
// they'd make the cross-app subject map to nothing by construction.
if( static_cast<SCH_SYMBOL*>( item )->IsPower() )
continue;
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()
{
@ -1903,7 +2021,11 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
// Cross-app selection (0006).
function("kicadCollabGetSelectionFull", &schCollabGetSelectionFull);
function("kicadCollabTestGetCrossMapped", &schCollabTestGetCrossMapped);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent);
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
#endif // !KICAD_MERGED_EMBIND
}