presence: fix stale peer selections after delete, select-all lag, ghost-peer removal (findings group Y)
- wasm core: CORE::onDocChanged() from both collab listeners (local commit AND remote apply) repaints peers' shapes from the live document + re-checks the local selection post-settle; PresenceStart registers the bridge listener - wasm core: cursors on their own overlay trio; shapes repaint only when the non-cursor snapshot changes; new kicadCollabSetRemoteCursors (cursor-only update, PEER.id) in both TUs + merged editor + JSPI mutator allowlist - presence.ts: size-aware trailing throttle for cursor/viewport publishes (128 KB/s budget) + parsed-peers memo; presence-kicad.ts: cursor-only push when the shape signature is unchanged - gateway.ts: honor the `gone` control (removeAwarenessStates) - specs: kicad stale-after-delete gate, web ghost-peer timing (+diag), unit select-all budget (pcbjam-shared → 4573a7c) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AScTR39aqyrY5i3ZFHmnMn
This commit is contained in:
parent
d069b7be80
commit
7a9aeb5181
13 changed files with 935 additions and 26 deletions
|
|
@ -46,6 +46,7 @@ namespace pcbjam_presence {
|
|||
|
||||
struct PEER
|
||||
{
|
||||
std::string id; // awareness identity (cursor-only updates key on it)
|
||||
std::string name;
|
||||
KIGFX::COLOR4D color;
|
||||
bool hasCursor = false;
|
||||
|
|
@ -135,9 +136,19 @@ struct CORE
|
|||
// chips — see the depth-layering note in collab_presence_style.h.
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> chipOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
|
||||
// Cursors live on their own overlay trio (findings Y-4): a peer's 20 Hz
|
||||
// cursor tick repaints these only; the selection/xsel/pin shapes above
|
||||
// are repainted only when a selection, lock, pin or zoom changes.
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorChipOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorTextOverlay;
|
||||
|
||||
bool started = false;
|
||||
bool redrawScheduled = false;
|
||||
bool shapesDirty = false;
|
||||
bool cursorsDirty = false;
|
||||
bool docChangeScheduled = false;
|
||||
std::string lastShapeSig; // setRemote dedupe: shapes vs cursor-only change
|
||||
bool selCheckScheduled = false;
|
||||
std::string lastSelectionJson; // dedupe: emit only when the payload changed
|
||||
long long lastCursorEmitMs = 0;
|
||||
|
|
@ -328,9 +339,11 @@ struct CORE
|
|||
|
||||
// ── remote render ─────────────────────────────────────────────────────
|
||||
|
||||
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE via the
|
||||
// Repaint the remote-peers overlays. Runs in CallAfter + COROUTINE via the
|
||||
// apply queue — serialized with the applies, same constraint as every
|
||||
// other view mutation from JS.
|
||||
// other view mutation from JS. Two groups, each repainted only when
|
||||
// dirty: SHAPES (selection boxes, cross-app ghosts, comment pins — the
|
||||
// expensive part: per-item resolve + outline geometry) and CURSORS.
|
||||
void redrawOverlay()
|
||||
{
|
||||
redrawScheduled = false;
|
||||
|
|
@ -359,9 +372,20 @@ struct CORE
|
|||
if( !textOverlay )
|
||||
textOverlay = makePresenceTextOverlay( view );
|
||||
|
||||
overlay->Clear();
|
||||
chipOverlay->Clear();
|
||||
textOverlay->Clear();
|
||||
if( !cursorOverlay )
|
||||
{
|
||||
cursorOverlay = view->MakeOverlay();
|
||||
cursorOverlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
|
||||
}
|
||||
|
||||
if( !cursorChipOverlay )
|
||||
{
|
||||
cursorChipOverlay = view->MakeOverlay();
|
||||
cursorChipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
|
||||
}
|
||||
|
||||
if( !cursorTextOverlay )
|
||||
cursorTextOverlay = makePresenceTextOverlay( view );
|
||||
|
||||
// Screen-constant sizing: px → world units, so cursors/outline widths
|
||||
// don't scale with zoom. MUST go through the GAL matrix
|
||||
|
|
@ -369,36 +393,72 @@ struct CORE
|
|||
// px-per-IU, and under-sizes the drawing by ~7 orders of magnitude.
|
||||
double px = view->ToWorld( 1.0 );
|
||||
|
||||
for( const PEER& peer : peers )
|
||||
bool shapes = shapesDirty;
|
||||
bool cursors = cursorsDirty;
|
||||
shapesDirty = false;
|
||||
cursorsDirty = false;
|
||||
|
||||
if( shapes )
|
||||
{
|
||||
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
|
||||
overlay->Clear();
|
||||
chipOverlay->Clear();
|
||||
textOverlay->Clear();
|
||||
|
||||
// Selection boxes + cross-app ghosts: editor-specific resolution.
|
||||
drawPeerShapes( *this, fr, peer, color, px );
|
||||
for( const PEER& peer : peers )
|
||||
{
|
||||
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
|
||||
|
||||
if( peer.hasCursor )
|
||||
drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor,
|
||||
peer.name, color, px, style );
|
||||
// Selection boxes + cross-app ghosts: editor-specific resolution.
|
||||
drawPeerShapes( *this, fr, peer, color, px );
|
||||
}
|
||||
|
||||
// Comment pin dots (0005) — on the CHIPS layer so selection fills
|
||||
// can't reject their fragments (see drawPin).
|
||||
for( const PIN& pin : pins )
|
||||
{
|
||||
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
|
||||
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style );
|
||||
}
|
||||
|
||||
view->Update( overlay.get() );
|
||||
view->Update( chipOverlay.get() );
|
||||
view->Update( textOverlay.get() );
|
||||
}
|
||||
|
||||
// Comment pin dots (0005) — on the CHIPS layer so selection fills
|
||||
// can't reject their fragments (see drawPin).
|
||||
for( const PIN& pin : pins )
|
||||
if( cursors )
|
||||
{
|
||||
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
|
||||
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style );
|
||||
cursorOverlay->Clear();
|
||||
cursorChipOverlay->Clear();
|
||||
cursorTextOverlay->Clear();
|
||||
|
||||
for( const PEER& peer : peers )
|
||||
{
|
||||
if( !peer.hasCursor )
|
||||
continue;
|
||||
|
||||
KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color );
|
||||
drawCursor( cursorOverlay.get(), cursorChipOverlay.get(), cursorTextOverlay.get(),
|
||||
peer.cursor, peer.name, color, px, style );
|
||||
}
|
||||
|
||||
view->Update( cursorOverlay.get() );
|
||||
view->Update( cursorChipOverlay.get() );
|
||||
view->Update( cursorTextOverlay.get() );
|
||||
}
|
||||
|
||||
view->Update( overlay.get() );
|
||||
view->Update( chipOverlay.get() );
|
||||
view->Update( textOverlay.get() );
|
||||
if( !shapes && !cursors )
|
||||
return;
|
||||
|
||||
// The canvas repaints on its own only with focus/input — force it,
|
||||
// exactly as the cross-probe flash does.
|
||||
fr->GetCanvas()->ForceRefresh();
|
||||
}
|
||||
|
||||
void scheduleRedraw()
|
||||
void scheduleRedraw( bool aShapes = true, bool aCursors = true )
|
||||
{
|
||||
shapesDirty = shapesDirty || aShapes;
|
||||
cursorsDirty = cursorsDirty || aCursors;
|
||||
|
||||
if( redrawScheduled )
|
||||
return;
|
||||
|
||||
|
|
@ -411,6 +471,39 @@ struct CORE
|
|||
pcbjam_collab::runOnCoroutine( fr, [this]() { redrawOverlay(); } );
|
||||
}
|
||||
|
||||
/** The document changed (local commit OR remote apply — findings Y-1/Y-3):
|
||||
* peers' selection boxes may now sit on deleted/moved items, and the
|
||||
* local selection may have lost items with no closing canvas event.
|
||||
* Queued on the apply coroutine so it runs AFTER the commit/apply body
|
||||
* that raised it: repaint the shapes from the live document and re-check
|
||||
* the local selection. Coalesced per settle. */
|
||||
void onDocChanged()
|
||||
{
|
||||
if( docChangeScheduled )
|
||||
return;
|
||||
|
||||
EDA_DRAW_FRAME* fr = frame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
docChangeScheduled = true;
|
||||
|
||||
pcbjam_collab::runOnCoroutine( fr, [this]()
|
||||
{
|
||||
docChangeScheduled = false;
|
||||
|
||||
if( !peers.empty() || !pins.empty() )
|
||||
{
|
||||
shapesDirty = true;
|
||||
cursorsDirty = true;
|
||||
redrawOverlay();
|
||||
}
|
||||
|
||||
checkSelection();
|
||||
} );
|
||||
}
|
||||
|
||||
// ── JS entry-point bodies ─────────────────────────────────────────────
|
||||
|
||||
/** kicadCollabPresenceStart: install the input hooks on the GAL canvas
|
||||
|
|
@ -491,6 +584,7 @@ struct CORE
|
|||
for( const json& p : j.value( "peers", json::array() ) )
|
||||
{
|
||||
PEER peer;
|
||||
peer.id = p.value( "id", "" );
|
||||
peer.name = p.value( "name", "" );
|
||||
peer.color = parsePeerColor( p.value( "color", "" ) );
|
||||
|
||||
|
|
@ -528,10 +622,75 @@ struct CORE
|
|||
parsedLocks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" );
|
||||
}
|
||||
|
||||
// Shapes signature (everything but cursors): an unchanged one means
|
||||
// this push is a cursor tick — repaint cursors only.
|
||||
std::string sig;
|
||||
|
||||
for( const PEER& peer : parsed )
|
||||
{
|
||||
sig += peer.id + '\x1f' + peer.name + '\x1f' + peer.color.ToCSSString().ToStdString() + '\x1e';
|
||||
|
||||
for( const KIID& k : peer.selection )
|
||||
sig += pcbjam_collab::toUtf8( k.AsString() ) + ',';
|
||||
|
||||
sig += '\x1e';
|
||||
|
||||
for( const KIID& k : peer.xsel )
|
||||
sig += pcbjam_collab::toUtf8( k.AsString() ) + ',';
|
||||
|
||||
sig += '\x1d';
|
||||
}
|
||||
|
||||
for( const auto& [id, name] : parsedLocks )
|
||||
sig += pcbjam_collab::toUtf8( id.AsString() ) + '=' + name + ';';
|
||||
|
||||
bool shapesChanged = sig != lastShapeSig;
|
||||
lastShapeSig = std::move( sig );
|
||||
|
||||
peers = std::move( parsed );
|
||||
locks = std::move( parsedLocks );
|
||||
start();
|
||||
scheduleRedraw();
|
||||
scheduleRedraw( shapesChanged, true );
|
||||
}
|
||||
|
||||
/** kicadCollabSetRemoteCursors (findings Y-4): `{cursors:[{id,cursor:{x,y}|null}]}`
|
||||
* for peers of the last full snapshot — updates their cursors and repaints
|
||||
* the cursor overlays only. Unknown ids are ignored (the next full
|
||||
* snapshot introduces them). */
|
||||
void setRemoteCursors( const std::string& aJson )
|
||||
{
|
||||
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( j.is_discarded() )
|
||||
return;
|
||||
|
||||
bool changed = false;
|
||||
|
||||
for( const json& c : j.value( "cursors", json::array() ) )
|
||||
{
|
||||
std::string id = c.is_object() ? c.value( "id", "" ) : "";
|
||||
|
||||
for( PEER& peer : peers )
|
||||
{
|
||||
if( peer.id != id )
|
||||
continue;
|
||||
|
||||
if( c.contains( "cursor" ) && c["cursor"].is_object() )
|
||||
{
|
||||
peer.hasCursor = true;
|
||||
peer.cursor = VECTOR2D( c["cursor"].value( "x", 0.0 ), c["cursor"].value( "y", 0.0 ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
peer.hasCursor = false;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if( changed )
|
||||
scheduleRedraw( false, true );
|
||||
}
|
||||
|
||||
/** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y,
|
||||
|
|
|
|||
|
|
@ -650,6 +650,12 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
|
|||
// 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();
|
||||
// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints
|
||||
// the peers' overlay from the live document and re-checks the local selection,
|
||||
// post-settle on the apply coroutine. Runs BEFORE the applying-remote early
|
||||
// return below — the remote apply is exactly the case that left peers' boxes on
|
||||
// deleted items.
|
||||
void schedulePresenceDocChanged();
|
||||
|
||||
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
|
||||
{
|
||||
|
|
@ -684,6 +690,8 @@ private:
|
|||
// symbol — noteDirty), then coalesce into one post-settle flush.
|
||||
void trigger( const std::vector<SCH_ITEM*>& aItems )
|
||||
{
|
||||
schedulePresenceDocChanged();
|
||||
|
||||
if( s_applyingRemote )
|
||||
return;
|
||||
|
||||
|
|
@ -829,6 +837,11 @@ void schedulePresenceSelCheck()
|
|||
presenceCore().scheduleSelCheck();
|
||||
}
|
||||
|
||||
void schedulePresenceDocChanged()
|
||||
{
|
||||
presenceCore().onDocChanged();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -1839,6 +1852,11 @@ extern "C" void kicadCollabOnSave( const char* aPath )
|
|||
// sheet navigation, so one install serves the whole session.
|
||||
void schCollabPresenceStart()
|
||||
{
|
||||
// Presence needs the document listener too (findings Y-1/Y-3: doc changes
|
||||
// repaint peers' shapes + re-check the local selection) — collab normally
|
||||
// registers it first via snapshot/apply, but presence must not depend on
|
||||
// that ordering (test harnesses, presence-only sessions).
|
||||
ensureBridge();
|
||||
presenceCore().start();
|
||||
}
|
||||
|
||||
|
|
@ -1850,6 +1868,12 @@ void schCollabSetRemote( std::string aJson )
|
|||
presenceCore().setRemote( aJson );
|
||||
}
|
||||
|
||||
// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4).
|
||||
void schCollabSetRemoteCursors( std::string aJson )
|
||||
{
|
||||
presenceCore().setRemoteCursors( aJson );
|
||||
}
|
||||
|
||||
// JS → C++ (collab-presence 0005): comment pin dots (same wire as pcbnew).
|
||||
void schCollabSetPins( std::string aJson )
|
||||
{
|
||||
|
|
@ -2241,6 +2265,7 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
|
||||
function("kicadCollabPresenceStart", &schCollabPresenceStart);
|
||||
function("kicadCollabSetRemote", &schCollabSetRemote);
|
||||
function("kicadCollabSetRemoteCursors", &schCollabSetRemoteCursors);
|
||||
function("kicadCollabSetPins", &schCollabSetPins);
|
||||
function("kicadCollabSetViewport", &schCollabSetViewport);
|
||||
// Follow-user (collab-presence 0008).
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ int pcbCollabTestUndoDepth();
|
|||
// Presence (collab-presence 0002) + comment pins/panning (0005).
|
||||
void pcbCollabPresenceStart();
|
||||
void pcbCollabSetRemote( std::string aJson );
|
||||
void pcbCollabSetRemoteCursors( std::string aJson );
|
||||
void pcbCollabSetPins( std::string aJson );
|
||||
void pcbCollabSetViewport( double aCx, double aCy );
|
||||
// Follow-user (collab-presence 0008).
|
||||
|
|
@ -120,6 +121,7 @@ int schCollabTestUndoDepth();
|
|||
// Presence (collab-presence 0003 — eeschema counterparts) + pins (0005).
|
||||
void schCollabPresenceStart();
|
||||
void schCollabSetRemote( std::string aJson );
|
||||
void schCollabSetRemoteCursors( std::string aJson );
|
||||
void schCollabSetPins( std::string aJson );
|
||||
void schCollabSetViewport( double aCx, double aCy );
|
||||
// Follow-user (collab-presence 0008).
|
||||
|
|
@ -491,6 +493,11 @@ static void collabSetRemote( std::string aJson )
|
|||
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
|
||||
}
|
||||
|
||||
static void collabSetRemoteCursors( std::string aJson )
|
||||
{
|
||||
pcbEditorActive() ? pcbCollabSetRemoteCursors( aJson ) : schCollabSetRemoteCursors( aJson );
|
||||
}
|
||||
|
||||
static void collabSetPins( std::string aJson )
|
||||
{
|
||||
pcbEditorActive() ? pcbCollabSetPins( aJson ) : schCollabSetPins( aJson );
|
||||
|
|
@ -658,6 +665,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
|||
// Presence (collab-presence 0002/0003) + comment pins/panning (0005).
|
||||
function("kicadCollabPresenceStart", &collabPresenceStart);
|
||||
function("kicadCollabSetRemote", &collabSetRemote);
|
||||
function("kicadCollabSetRemoteCursors", &collabSetRemoteCursors);
|
||||
function("kicadCollabSetPins", &collabSetPins);
|
||||
function("kicadCollabSetViewport", &collabSetViewport);
|
||||
// Live color-theme switch (comments-ux 0002 F4).
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,10 @@ void scheduleFlush()
|
|||
// (delete, paste) with no closing canvas event — piggyback a selection re-check
|
||||
// on the collab listener trigger. Defined in the presence section below.
|
||||
void schedulePresenceSelCheck();
|
||||
// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints
|
||||
// the peers' overlay from the live board and re-checks the local selection —
|
||||
// runs BEFORE the applying-remote early return in trigger().
|
||||
void schedulePresenceDocChanged();
|
||||
|
||||
// ChangeSource: the native BOARD_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
|
||||
|
|
@ -1034,6 +1038,8 @@ private:
|
|||
// may be freed before the flush runs), then coalesce into one flush.
|
||||
void trigger( const std::vector<BOARD_ITEM*>& aItems )
|
||||
{
|
||||
schedulePresenceDocChanged();
|
||||
|
||||
if( s_applyingRemote )
|
||||
return;
|
||||
|
||||
|
|
@ -1454,6 +1460,11 @@ void schedulePresenceSelCheck()
|
|||
presenceCore().scheduleSelCheck();
|
||||
}
|
||||
|
||||
void schedulePresenceDocChanged()
|
||||
{
|
||||
presenceCore().onDocChanged();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -1685,6 +1696,8 @@ std::string pcbCollabGetPos( std::string aId )
|
|||
// run POST-event via CallAfter (the selection tool acts on the same event after us).
|
||||
void pcbCollabPresenceStart()
|
||||
{
|
||||
// Presence needs the board listener too (findings Y-1/Y-3) — see eeschema.
|
||||
ensureBridge();
|
||||
presenceCore().start();
|
||||
}
|
||||
|
||||
|
|
@ -1696,6 +1709,12 @@ void pcbCollabSetRemote( std::string aJson )
|
|||
presenceCore().setRemote( aJson );
|
||||
}
|
||||
|
||||
// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4).
|
||||
void pcbCollabSetRemoteCursors( std::string aJson )
|
||||
{
|
||||
presenceCore().setRemoteCursors( aJson );
|
||||
}
|
||||
|
||||
// JS → C++ (collab-presence 0005): comment pin dots — `{pins:[{id,x,y,color,
|
||||
// resolved}]}`, world IU coords resolved by the TS side from the ydoc anchors.
|
||||
// Snapshot semantics like SetRemote: cleared + fully redrawn each push.
|
||||
|
|
@ -2897,6 +2916,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
// with 0003 (the merged image dispatches pcb-only until then).
|
||||
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
|
||||
function("kicadCollabSetRemote", &pcbCollabSetRemote);
|
||||
function("kicadCollabSetRemoteCursors", &pcbCollabSetRemoteCursors);
|
||||
function("kicadCollabSetPins", &pcbCollabSetPins);
|
||||
function("kicadCollabSetViewport", &pcbCollabSetViewport);
|
||||
// Follow-user (collab-presence 0008).
|
||||
|
|
|
|||
Loading…
Reference in a new issue