pcbjam/wasm/bindings/collab_presence_core.h
Gergő Törcsvári 855137c650
refactor(collab): dedup embind collab/presence layer into shared headers (collab_common.h + collab_presence_core.h)
The eeschema/pcbnew binding TUs had ~1000 lines of copy-pasted collab code.
Factored into two header-only shared files (zero build-script changes — the
collab_presence_style.h precedent):

- collab_common.h (pcbjam_collab): toUtf8, runOnFiber (the CallAfter+COROUTINE
  fiber idiom — was ~25 inline copies), the window.kicadCollab wire emitters
  (onDelta/onItems/onCursor/onSelection/onViewport), frame-generic undo test
  hooks.
- collab_presence_core.h (pcbjam_presence::CORE): PEER/PIN + all presence
  state and machinery (start/canvas binds/lock query, setRemote/setPins/
  setStyle, selection check + dedupe, overlay redraw loop, viewport push/pull,
  releaseSelection, locks probe), written against the EDA_DRAW_FRAME +
  SELECTION_TOOL base classes. Per-editor hooks: frame, selectionTool,
  selectionEmitPayload, resolveItem, drawPeerShapes. One CORE instance per TU
  (anonymous-namespace presenceCore()) so the merged image keeps per-editor
  state separation.
- NEW per-editor resolveXsel(frame, peer): ONE cross-app resolver shared by
  the ghost render AND kicadCollabTestGetCrossMapped — the mapping loop was
  duplicated within each TU, letting the test probe drift from the pixels.

Deliberately NOT factored: the Yjs differ/apply halves (itemToJson/makeItem/
flushDiff/doApply*) — structurally parallel but the bodies encode per-editor
sync semantics and editor-specific asyncify devirtualization workarounds that
must stay visible. kicadOpenFile/kicadCollabOnSave keep the existing
KICAD_MERGED_EMBIND mechanism. TestClearSelection stays editor-typed
(ClearSelection is not on the SELECTION_TOOL base).

eeschema_embind 2203→1721 lines, pcbnew_embind 2518→1993. JS-facing names,
signatures and the kicad_editor_embind.cpp dispatcher are unchanged.

Verified: kicad_editor image builds clean; tests/kicad presence+locks 18/18
(incl. ghost-render pixel compares), collab+ysync-repros 31 passed/2 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
2026-07-10 09:17:32 +02:00

612 lines
21 KiB
C++

/*
* Collab presence core (collab-presence 0002/0003/0005/0006/0007) — the
* editor-agnostic half of the presence layer, shared by the pcbnew and
* eeschema binding TUs so the two implementations can't drift.
*
* Everything here talks only to the shared KiCad base classes
* (EDA_DRAW_FRAME, SELECTION_TOOL, KIGFX::VIEW) — the small per-editor
* remainder (how to resolve a KIID, how to draw one peer's selection/xsel,
* what the selection emit payload carries) plugs in through CORE's hooks.
* Each binding TU owns ONE CORE instance in its anonymous namespace, so the
* merged kicad_editor image keeps today's per-editor state separation.
*
* The drawing itself (shapes, labels, cursors, pins, style knobs) stays in
* collab_presence_style.h; this header owns the state + event/scheduling
* machinery around it.
*/
#pragma once
#ifdef __EMSCRIPTEN__
#include <chrono>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <wx/event.h>
#include <wx/string.h>
#include <nlohmann/json.hpp>
#include <class_draw_panel_gal.h>
#include <eda_draw_frame.h>
#include <eda_item.h>
#include <kiid.h>
#include <pcbjam_remote_lock.h>
#include <tool/selection_tool.h>
#include <tool/tool_manager.h>
#include <view/view.h>
#include <view/view_overlay.h>
#include "collab_common.h"
#include "collab_presence_style.h"
namespace pcbjam_presence {
struct PEER
{
std::string name;
KIGFX::COLOR4D color;
bool hasCursor = false;
VECTOR2D cursor; // world coords (IU)
std::vector<KIID> selection;
// Cross-app selection (0006): the peer's selection in the OTHER editor,
// as SYMBOL uuids (pcbnew footprint paths are stripped to their tail by
// the TS side). Ghost-rendered by the per-editor drawPeerShapes hook.
std::vector<KIID> xsel;
};
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
// zero pan/zoom lag; the clickable hit target + thread popover are DOM.
struct PIN
{
std::string id;
std::string name; // author (palette-override rehash key)
VECTOR2D pos; // world coords (IU)
KIGFX::COLOR4D color;
bool resolved = false;
};
inline long long nowMs()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch() )
.count();
}
inline KIGFX::COLOR4D parsePeerColor( 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
}
/**
* Per-editor presence state + machinery. One instance per binding TU; the
* hooks below are set once at construction (see presenceCore() in each TU).
*/
struct CORE
{
using json = nlohmann::json;
// ── per-editor hooks ──────────────────────────────────────────────────
/** The live editor frame, or nullptr (the usual dynamic_cast probe). */
std::function<EDA_DRAW_FRAME*()> frame;
/** The editor's selection tool (GetTool<> needs the concrete type). */
std::function<SELECTION_TOOL*( EDA_DRAW_FRAME* )> selectionTool;
/** What checkSelection hands to JS: a bare uuid array (eeschema) or the
* 0006 {uuids, fpPaths} payload (pcbnew). */
std::function<json( EDA_DRAW_FRAME* )> selectionEmitPayload;
/** Resolve a KIID to a live item, or nullptr (releaseSelection). */
std::function<EDA_ITEM*( EDA_DRAW_FRAME*, const KIID& )> resolveItem;
/** Draw ONE peer's selection boxes + cross-app ghosts into the overlays
* (item resolution and exact-outline rendering are editor-specific).
* Cursors and pins are drawn by the shared redraw loop. */
std::function<void( CORE&, EDA_DRAW_FRAME*, const PEER&, const KIGFX::COLOR4D&, double )>
drawPeerShapes;
// ── state ─────────────────────────────────────────────────────────────
std::vector<PEER> peers;
std::vector<PIN> pins;
// Remote soft-locks (0007): uuid → holding peer's display name, derived
// by the TS side from ALL other clients' live selections (own user's
// other tabs included). Consulted by the fork's PCBJAM_REMOTE_LOCK query
// from the selection/move tools. Ephemeral — replaced on every setRemote.
std::map<KIID, std::string> locks;
// Every visual knob — see collab_presence_style.h; live-patched by
// kicadCollabSetStyle (tuner). Editors seed their own defaults.
STYLE style;
std::shared_ptr<KIGFX::VIEW_OVERLAY> overlay;
// Labels render from their own overlay at the nearest depth (chip rects
// would otherwise erase same-depth text) — see PRESENCE_TEXT_OVERLAY.
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
bool started = false;
bool redrawScheduled = false;
bool selCheckScheduled = false;
std::string lastSelectionJson; // dedupe: emit only when the payload changed
long long lastCursorEmitMs = 0;
double lastVpScale = 0.0;
VECTOR2D lastVpCenter;
// ── local state emit (selection / cursor / viewport) ──────────────────
json selectionUuids( EDA_DRAW_FRAME* aFrame )
{
json uuids = json::array();
SELECTION_TOOL* selTool = selectionTool( aFrame );
if( !selTool )
return uuids;
for( EDA_ITEM* item : selTool->GetSelection() )
uuids.push_back( pcbjam_collab::toUtf8( item->m_Uuid.AsString() ) );
return uuids;
}
// Post-settle selection emit: read the selection AFTER the triggering event
// finished (CallAfter), dedupe against the last emitted set, hand to JS.
void checkSelection()
{
selCheckScheduled = false;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
std::string s = selectionEmitPayload( fr ).dump();
if( s == lastSelectionJson )
return;
lastSelectionJson = s;
pcbjam_collab::emitSelection( s );
}
void scheduleSelCheck()
{
if( selCheckScheduled )
return;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
selCheckScheduled = true;
fr->CallAfter( [this]() { checkSelection(); } );
}
// Viewport push (world↔screen mapping for the DOM layers, 0005). Zoom also
// invalidates the overlay's screen-constant sizes → schedule a redraw.
void emitViewportIfChanged()
{
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
double scale = view->GetScale(); // zoom — cheap change detector only
VECTOR2D c = view->GetCenter();
if( scale == lastVpScale && c == lastVpCenter )
return;
lastVpScale = scale;
lastVpCenter = c;
const VECTOR2I& sz = view->GetScreenPixelSize();
// px per IU via the GAL matrix — GetScale() is the zoom, not px/IU.
pcbjam_collab::emitViewport( c.x, c.y, view->ToScreen( 1.0 ), sz.x, sz.y );
if( !peers.empty() )
scheduleRedraw();
}
/** kicadCollabGetViewport: `{cx,cy,scale,w,h}`, scale = px per IU via the
* GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson). */
std::string viewportJson()
{
EDA_DRAW_FRAME* fr = frame();
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();
}
/** kicadCollabSetViewport (0005): pan to a world position (comment panel
* "jump to pin"). Fiber like every other view mutation from JS. */
void panTo( double aCx, double aCy )
{
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy]() {
fr->GetCanvas()->GetView()->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
} );
}
void onMotion( wxMouseEvent& aEvt )
{
aEvt.Skip();
long long now = nowMs();
if( now - lastCursorEmitMs < 50 ) // ≤20 emits/s, event-driven (no timers)
return;
lastCursorEmitMs = now;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
// Screen→world via the non-virtual VIEW::ToWorld (the virtual
// VIEW_CONTROLS::GetMousePosition is an asyncify dispatch risk here).
wxPoint p = aEvt.GetPosition();
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
pcbjam_collab::emitCursor( world.x, world.y, true );
emitViewportIfChanged(); // catches drag-pan while moving
}
void onLeave( wxMouseEvent& aEvt )
{
aEvt.Skip();
pcbjam_collab::emitCursor( 0, 0, false );
}
// ── remote render ─────────────────────────────────────────────────────
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE: the
// first MakeOverlay() view->Add and the items' virtual ViewBBox() need the
// fiber stack (asyncify virtual dispatch — same constraint as the apply).
void redrawOverlay()
{
redrawScheduled = false;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
if( !overlay )
{
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 );
}
if( !textOverlay )
textOverlay = makePresenceTextOverlay( view );
overlay->Clear();
textOverlay->Clear();
// Screen-constant sizing: px → world units, so cursors/outline widths
// don't scale with zoom. MUST go through the GAL matrix
// (ToWorld(double)) — the naive 1/GetScale() is the ZOOM factor, not
// px-per-IU, and under-sizes the drawing by ~7 orders of magnitude.
double px = view->ToWorld( 1.0 );
for( const PEER& peer : peers )
{
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
// Selection boxes + cross-app ghosts: editor-specific resolution.
drawPeerShapes( *this, fr, peer, color, px );
if( peer.hasCursor )
drawCursor( overlay.get(), textOverlay.get(), peer.cursor, peer.name, color,
px, style );
}
// Comment pin dots (0005), drawn last so they sit above selection outlines.
for( const PIN& pin : pins )
{
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( overlay.get(), pin.pos, color, pin.resolved, px, style );
}
view->Update( overlay.get() );
view->Update( textOverlay.get() );
// The canvas repaints on its own only with focus/input — force it,
// exactly as the cross-probe flash does.
fr->GetCanvas()->ForceRefresh();
}
void scheduleRedraw()
{
if( redrawScheduled )
return;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
redrawScheduled = true;
pcbjam_collab::runOnFiber( fr, [this]() { redrawOverlay(); } );
}
// ── JS entry-point bodies ─────────────────────────────────────────────
/** kicadCollabPresenceStart: install the input hooks on the GAL canvas
* (idempotent — the canvas is the same window for the whole session) and
* the fork's remote soft-lock query (0007). */
void start()
{
EDA_DRAW_FRAME* fr = frame();
if( !fr || started )
return;
started = true;
// Remote soft-locks (0007): let the selection/move tools consult the
// peers' live selections through the fork's process-global query.
PCBJAM_REMOTE_LOCK::SetQuery(
[this]( const KIID& aId, wxString* aHolder ) -> bool
{
auto it = locks.find( aId );
if( it == locks.end() )
return false;
if( aHolder )
*aHolder = wxString::FromUTF8( it->second.c_str() );
return true;
} );
wxWindow* canvas = fr->GetCanvas();
canvas->Bind( wxEVT_MOTION, [this]( wxMouseEvent& e ) { onMotion( e ); } );
canvas->Bind( wxEVT_LEAVE_WINDOW, [this]( wxMouseEvent& e ) { onLeave( e ); } );
// Handlers Skip() so the view controls' own processing is untouched;
// selection checks run POST-event via CallAfter (the selection tool
// acts on the same event after us).
auto selAndViewport = [this]( wxEvent& e )
{
e.Skip();
scheduleSelCheck();
if( EDA_DRAW_FRAME* f = frame() )
f->CallAfter( [this]() { 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 ); } );
}
/** kicadCollabSetRemote: full remote-peers snapshot — `{peers:[{id,name,
* color,cursor:{x,y}|null,selection:[uuid],xsel:[uuid]}],locks:[{uuid,
* name}]}`, trivially derived from awareness.getStates() and idempotent
* (the overlay is cleared + fully redrawn). Empty peers clears it. */
void setRemote( const std::string& aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
std::vector<PEER> parsed;
for( const json& p : j.value( "peers", json::array() ) )
{
PEER peer;
peer.name = p.value( "name", "" );
peer.color = parsePeerColor( 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() ) );
}
// Cross-app selection (0006): symbol uuids from the OTHER editor.
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() ) );
}
parsed.push_back( std::move( peer ) );
}
// Remote soft-locks (0007): every other client's held uuids with the
// holder's display name for the infobar.
std::map<KIID, std::string> parsedLocks;
for( const json& l : j.value( "locks", json::array() ) )
{
std::string uuid = l.is_object() ? l.value( "uuid", "" ) : "";
if( !uuid.empty() )
parsedLocks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" );
}
peers = std::move( parsed );
locks = std::move( parsedLocks );
start();
scheduleRedraw();
}
/** kicadCollabSetPins (0005): comment pin dots — `{pins:[{id,name,x,y,
* color,resolved}]}`, world IU coords resolved by the TS side from the
* ydoc anchors. Snapshot semantics like setRemote. */
void setPins( const std::string& aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
std::vector<PIN> parsed;
for( const json& p : j.value( "pins", json::array() ) )
{
PIN pin;
pin.id = p.value( "id", "" );
pin.name = p.value( "name", "" );
pin.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) );
pin.color = parsePeerColor( p.value( "color", "" ) );
pin.resolved = p.value( "resolved", false );
parsed.push_back( std::move( pin ) );
}
pins = std::move( parsed );
start();
scheduleRedraw();
}
/** kicadCollabSetStyle (presence tuner): live-patch the overlay STYLE
* (partial JSON — see collab_presence_style.h) and repaint. */
void setStyle( const std::string& aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
patchStyle( style, j );
scheduleRedraw();
}
/** kicadCollabReleaseSelection (0007): the local client LOST the selection
* tiebreak — release the contested items (only those) from the live
* selection. If an interactive tool (move/drag) holds them, cancel it
* first (ESC semantics — the preview reverts); a bare cancel is NOT sent
* when idle, since ESC on the base selection tool would clear the whole
* selection. Ends with a forced selection re-emit (programmatic changes
* close no canvas event). */
void releaseSelection( const std::string& aUuidsJson, const std::string& aHolder )
{
json j = json::parse( aUuidsJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() || !j.is_array() )
return;
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
std::vector<KIID> ids;
for( const json& u : j )
{
if( u.is_string() )
ids.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
}
if( ids.empty() )
return;
wxString holder = wxString::FromUTF8( aHolder.c_str() );
fr->CallAfter( [this, fr, ids, holder]() {
if( !fr->ToolStackIsEmpty() )
fr->GetToolManager()->RunAction( ACTIONS::cancelInteractive );
SELECTION_TOOL* st = selectionTool( fr );
if( !st )
return;
bool released = false;
for( const KIID& id : ids )
{
EDA_ITEM* item = resolveItem( fr, id );
if( item && item->IsSelected() )
{
st->RemoveItemFromSel( item );
released = true;
}
}
if( released )
{
fr->ShowInfoBarWarning( wxString::Format( _( "%s is editing this — released from "
"your selection." ),
holder ),
true );
}
scheduleSelCheck();
} );
}
/** Test helper: REALLY select an item through the selection tool, then run
* the presence check (programmatic selects close no canvas event). */
void selectItem( EDA_ITEM* aItem )
{
EDA_DRAW_FRAME* fr = frame();
if( !fr )
return;
fr->CallAfter( [this, fr, aItem]() {
if( SELECTION_TOOL* st = selectionTool( fr ) )
{
st->AddItemToSel( aItem );
scheduleSelCheck();
}
} );
}
/** Test probe (0007): the current remote soft-lock set as `[{uuid, name}]`. */
std::string locksJson()
{
json arr = json::array();
for( const auto& [id, name] : locks )
arr.push_back( { { "uuid", pcbjam_collab::toUtf8( id.AsString() ) }, { "name", name } } );
return arr.dump();
}
};
} // namespace pcbjam_presence
#endif // __EMSCRIPTEN__