pcbjam/wasm/bindings/collab_presence_core.h
Gergő Törcsvári 21a96b0440
comments-ux: figma bubble pins, floating panel, seen/reactions/mentions UI, theme follow (0001 A–E + 0002)
- GAL pin = one closed polygon: round body, squared-off bottom-left corner
  ON the anchor; PIN gains unread (accent ring); tuner knobs; shipped
  defaults r9/ring4/alpha.9. DOM hit/highlight sized+offset from a LIVE
  pin-geometry radius store the tuner feeds.
- Floating comments panel: draggable (shared useDraggablePanel with
  always-onscreen restore; overlay FAB retrofitted), collapsible to header,
  header carries add/show-hide/mark-all; unread badges (rose on mention).
- Reactions (emoji-mart lazy, quick-row) + @-mention autocomplete
  (MentionInput; backend roster with presence/author fallback).
- Theme: ?theme= > storage > OS, no-flash boot, toggles (HomePage + overlay
  View row), boot-seeded pcbjam-dark schematic colors + kicadSetColorTheme /
  kicadSetDarkChrome bridges (canvas + wx chrome live flip), light/dark
  variants across all overlay surfaces.
- e2e: panel/seen/reactions/mentions/theme specs + resize-spec geometry;
  bumps pcbjam-shared (flat-key seen/reactions + listCollaborators) and
  wxwidgets (dark chrome) pointers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLwn1toiNKi1MgxGKnZTes
2026-07-24 13:21:22 +02:00

676 lines
24 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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;
bool unread = false; // comments-ux 0001 C: accent ring
};
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;
// Name chips + pin dots render one depth unit ABOVE the selection shapes
// (else an earlier-painted low-alpha fill rejects the chip's fragments and
// the tag washes out to the fill's alpha), and the text one unit above the
// 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;
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;
VECTOR2I lastVpSize;
// ── 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();
const VECTOR2I& sz = view->GetScreenPixelSize();
// Size participates in the dedupe: the JS worldToScreen maps through
// w/2,h/2, so a canvas resize (or the boot layout settling after the
// bind-time seed) with an unchanged scale/center must still re-push —
// else every DOM pin target is vertically offset until the next
// pan/zoom.
if( scale == lastVpScale && c == lastVpCenter && sz == lastVpSize )
return;
lastVpScale = scale;
lastVpCenter = c;
lastVpSize = sz;
// 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();
}
/** kicadCollabFitViewport (0008 follow-user): fit the given world RECT
* (center + half-extents, IU) into this canvas — contain, never crop:
* the follower's zoom is derived from ITS OWN canvas size, so leaders
* and followers on different monitors see the same world region.
* Fiber like every other view mutation from JS. */
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
EDA_DRAW_FRAME* fr = frame();
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
const VECTOR2I& sz = view->GetScreenPixelSize();
// Pixels-per-IU that CONTAINS the rect in both axes. GetScale() is
// the zoom, not px/IU (0002 lesson) — convert via the GAL matrix:
// zoom scales linearly with px/IU, so target zoom = current zoom ×
// (target px/IU ÷ current px/IU).
double pxPerIuNow = view->ToScreen( 1.0 );
double pxPerIuFit = std::min( sz.x / ( 2.0 * aHalfW ), sz.y / ( 2.0 * aHalfH ) );
if( pxPerIuNow > 0 && pxPerIuFit > 0 )
view->SetScale( view->GetScale() * ( pxPerIuFit / pxPerIuNow ) );
view->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
} );
}
/** 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 )
{
// Depth layering near→deep: text (0) < chips (1) < shapes (2) —
// fork SetDepthOffset; see collab_presence_style.h.
overlay = view->MakeOverlay();
overlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
}
if( !chipOverlay )
{
chipOverlay = view->MakeOverlay();
chipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
}
if( !textOverlay )
textOverlay = makePresenceTextOverlay( view );
overlay->Clear();
chipOverlay->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(), chipOverlay.get(), textOverlay.get(), peer.cursor,
peer.name, color, px, style );
}
// 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() );
// 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 ); } );
// Canvas resizes change the w/h half of the world↔screen transform
// without touching scale/center — re-push post-layout (CallAfter runs
// after the GAL's own onSize updated the screen size).
canvas->Bind( wxEVT_SIZE, [this]( wxSizeEvent& e )
{
e.Skip();
if( EDA_DRAW_FRAME* f = frame() )
f->CallAfter( [this]() { emitViewportIfChanged(); } );
} );
}
/** 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 pins — `{pins:[{id,name,x,y,
* color,resolved,unread}]}`, 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 );
pin.unread = p.value( "unread", 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__