feat(collab): dev-time presence style tuner (VITE_PRESENCE_TUNER=1)

Parametrizes every visual knob of the presence overlay so we can pick the
shipped look live, then wire the winners into the defaults:
- collab_presence_style.h: shared STYLE struct + drawing (now used by BOTH
  editor TUs — no more duplicated overlay code): selection shape (rect /
  corner brackets / underline / rounded rect / filled-only), border width +
  alpha, infill alpha, padding, corner radius; name tag show/size/chip-
  background/inside-outside/top-bottom/start-end-center/offset; cursor shape
  (cross / pointer / circle+dot), size/width/alpha + label knobs; fixed-color
  and palette-by-name-hash overrides (try palettes without changing what
  senders publish); pin radius/ring/alphas. Defaults == shipped look.
- kicadCollabSetStyle(json) live-patch export + kicadCollabTestListItems(n)
  (real KIIDs for synthetic previews); merged dispatch; pins now carry the
  author name so palette overrides recolor them consistently.
- PresenceTuner.tsx: floating dev panel (env-gated, tree-shaken otherwise) —
  grouped sliders/selects, demo peers+pins injection for SOLO tuning,
  localStorage persistence across reloads, Copy JSON export, reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-06 19:10:40 +02:00
commit ce99773af4
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 907 additions and 77 deletions

View file

@ -0,0 +1,385 @@
/*
* Presence overlay styling (collab-presence tuner) shared by the pcbnew and
* eeschema binding TUs so the drawing never diverges between editors.
*
* Every visual knob of the remote-presence rendering (selection boxes, name
* tags, cursors, comment pin dots) lives in STYLE, JSON-patchable at runtime
* via kicadCollabSetStyle the dev-time PresenceTuner panel drives it to find
* the look we want; the chosen values then become the defaults here.
* Defaults == the shipped look.
*/
#pragma once
#ifdef __EMSCRIPTEN__
#include <gal/color4d.h>
#include <geometry/eda_angle.h>
#include <math/util.h>
#include <math/box2.h>
#include <view/view_overlay.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <vector>
#include <wx/string.h>
namespace pcbjam_presence {
using json = nlohmann::json;
struct STYLE
{
// ── selection box ──────────────────────────────────────────────────────
// 0 rect · 1 corner brackets · 2 underline · 3 rounded rect · 4 filled only
int selShape = 0;
double selStrokeWidth = 2.5; // px
double selStrokeAlpha = 0.9;
double selFillAlpha = 0.0; // 0 = no fill
double selPaddingPx = 4.0; // bbox inflate
double selCornerPx = 8.0; // bracket arm length / rounding radius
// ── selection name tag ────────────────────────────────────────────────
bool labelShow = true;
double labelSizePx = 9.0;
bool labelChip = false; // filled background chip + white text
int labelVPos = 0; // 0 top · 1 bottom
int labelHPos = 0; // 0 start · 1 end · 2 center
bool labelInside = false; // inside vs outside the box
double labelOffsetPx = 8.0;
// ── remote cursor ─────────────────────────────────────────────────────
// 0 cross · 1 pointer triangle · 2 circle + dot
int cursorShape = 0;
double cursorSizePx = 7.0;
double cursorWidthPx = 2.0;
double cursorAlpha = 0.9;
bool cursorLabel = true;
double cursorLabelSizePx = 10.0;
bool cursorLabelChip = false;
// ── colors ────────────────────────────────────────────────────────────
// fixedColor: every peer in ONE color ("" = off). palette: recolor peers
// by name hash from this list ([] = off) — for trying palettes without
// changing what senders publish. Peer-provided color is the fallback.
std::string fixedColor;
std::vector<std::string> palette;
// ── comment pin dots ──────────────────────────────────────────────────
double pinRadiusPx = 7.0;
double pinRingPx = 1.5;
double pinRingAlpha = 0.9;
double pinFillAlpha = 1.0;
double pinResolvedAlpha = 0.3;
};
inline KIGFX::COLOR4D parseHexColor( const std::string& aHex, const KIGFX::COLOR4D& aFallback )
{
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, 1.0 );
}
return aFallback;
}
/** Patch aStyle from a (partial) JSON object — unknown keys ignored, absent
* keys keep their value, so the tuner can send full or incremental states. */
inline void patchStyle( STYLE& aStyle, const json& j )
{
aStyle.selShape = j.value( "selShape", aStyle.selShape );
aStyle.selStrokeWidth = j.value( "selStrokeWidth", aStyle.selStrokeWidth );
aStyle.selStrokeAlpha = j.value( "selStrokeAlpha", aStyle.selStrokeAlpha );
aStyle.selFillAlpha = j.value( "selFillAlpha", aStyle.selFillAlpha );
aStyle.selPaddingPx = j.value( "selPaddingPx", aStyle.selPaddingPx );
aStyle.selCornerPx = j.value( "selCornerPx", aStyle.selCornerPx );
aStyle.labelShow = j.value( "labelShow", aStyle.labelShow );
aStyle.labelSizePx = j.value( "labelSizePx", aStyle.labelSizePx );
aStyle.labelChip = j.value( "labelChip", aStyle.labelChip );
aStyle.labelVPos = j.value( "labelVPos", aStyle.labelVPos );
aStyle.labelHPos = j.value( "labelHPos", aStyle.labelHPos );
aStyle.labelInside = j.value( "labelInside", aStyle.labelInside );
aStyle.labelOffsetPx = j.value( "labelOffsetPx", aStyle.labelOffsetPx );
aStyle.cursorShape = j.value( "cursorShape", aStyle.cursorShape );
aStyle.cursorSizePx = j.value( "cursorSizePx", aStyle.cursorSizePx );
aStyle.cursorWidthPx = j.value( "cursorWidthPx", aStyle.cursorWidthPx );
aStyle.cursorAlpha = j.value( "cursorAlpha", aStyle.cursorAlpha );
aStyle.cursorLabel = j.value( "cursorLabel", aStyle.cursorLabel );
aStyle.cursorLabelSizePx = j.value( "cursorLabelSizePx", aStyle.cursorLabelSizePx );
aStyle.cursorLabelChip = j.value( "cursorLabelChip", aStyle.cursorLabelChip );
aStyle.fixedColor = j.value( "fixedColor", aStyle.fixedColor );
if( j.contains( "palette" ) && j["palette"].is_array() )
{
aStyle.palette.clear();
for( const json& c : j["palette"] )
{
if( c.is_string() )
aStyle.palette.push_back( c.get<std::string>() );
}
}
aStyle.pinRadiusPx = j.value( "pinRadiusPx", aStyle.pinRadiusPx );
aStyle.pinRingPx = j.value( "pinRingPx", aStyle.pinRingPx );
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
}
/** The color a peer renders with under this style (fixed > palette-by-name-hash
* > the sender-provided color). */
inline KIGFX::COLOR4D peerColor( const STYLE& aStyle, const std::string& aName,
const KIGFX::COLOR4D& aProvided )
{
if( !aStyle.fixedColor.empty() )
return parseHexColor( aStyle.fixedColor, aProvided );
if( !aStyle.palette.empty() )
{
unsigned h = 0x811c9dc5;
for( char c : aName )
{
h ^= (unsigned char) c;
h *= 0x01000193;
}
return parseHexColor( aStyle.palette[h % aStyle.palette.size()], aProvided );
}
return aProvided;
}
// Rough bitmap-font advance (the GAL stroke/bitmap glyphs are ~0.75 em wide) —
// good enough to size label chips and right-align labels for the tuner.
inline double textWidth( const std::string& aText, double aGlyphH )
{
return aText.size() * aGlyphH * 0.75;
}
/** Name tag next to (or inside) a box, per the label placement knobs. `px` is
* world-units-per-screen-pixel. BitmapText anchors near its position's left
* edge, vertically centered-ish offsets are tuned around that. */
inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, const BOX2I& aBox, const std::string& aText,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
if( !aS.labelShow || aText.empty() )
return;
double h = aS.labelSizePx * aPx;
double w = textWidth( aText, h );
double off = aS.labelOffsetPx * aPx;
double x = aBox.GetOrigin().x; // start
if( aS.labelHPos == 1 )
x = aBox.GetEnd().x - w; // end
else if( aS.labelHPos == 2 )
x = ( aBox.GetOrigin().x + aBox.GetEnd().x ) / 2.0 - w / 2.0; // center
double y;
if( aS.labelVPos == 0 )
y = aS.labelInside ? aBox.GetOrigin().y + off : aBox.GetOrigin().y - off;
else
y = aS.labelInside ? aBox.GetEnd().y - off : aBox.GetEnd().y + off;
if( aS.labelChip )
{
double padX = 3 * aPx, padY = 2.5 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( 0.92 ) );
aOv->Rectangle( VECTOR2D( x - padX, y - h / 2 - padY ),
VECTOR2D( x + w + padX, y + h / 2 + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) );
}
else
{
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aOv->SetStrokeColor( aColor );
}
aOv->SetGlyphSize( VECTOR2I( KiROUND( h ), KiROUND( h ) ) );
aOv->BitmapText( wxString::FromUTF8( aText.c_str() ), VECTOR2I( KiROUND( x ), KiROUND( y ) ),
ANGLE_0 );
}
/** Selection highlight for one item bbox, in the chosen shape. */
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, BOX2I aBox, const std::string& aName,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
aBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
const VECTOR2D tl = aBox.GetOrigin();
const VECTOR2D br = aBox.GetEnd();
const VECTOR2D tr( br.x, tl.y );
const VECTOR2D bl( tl.x, br.y );
bool fill = aS.selFillAlpha > 0.001 || aS.selShape == 4;
double fillAlpha = aS.selShape == 4 && aS.selFillAlpha <= 0.001 ? 0.18 : aS.selFillAlpha;
aOv->SetIsStroke( aS.selShape != 4 );
aOv->SetIsFill( fill );
aOv->SetStrokeColor( aColor.WithAlpha( aS.selStrokeAlpha ) );
aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) );
aOv->SetLineWidth( aS.selStrokeWidth * aPx );
switch( aS.selShape )
{
default:
case 0: // rectangle (fill rides along when selFillAlpha > 0)
case 4: // filled only
aOv->Rectangle( tl, br );
break;
case 1: // corner brackets
{
if( fill )
{
aOv->SetIsStroke( false );
aOv->Rectangle( tl, br );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
}
double arm = std::min( { aS.selCornerPx * aPx, ( br.x - tl.x ) / 2.0,
( br.y - tl.y ) / 2.0 } );
aOv->Line( tl, tl + VECTOR2D( arm, 0 ) );
aOv->Line( tl, tl + VECTOR2D( 0, arm ) );
aOv->Line( tr, tr + VECTOR2D( -arm, 0 ) );
aOv->Line( tr, tr + VECTOR2D( 0, arm ) );
aOv->Line( bl, bl + VECTOR2D( arm, 0 ) );
aOv->Line( bl, bl + VECTOR2D( 0, -arm ) );
aOv->Line( br, br + VECTOR2D( -arm, 0 ) );
aOv->Line( br, br + VECTOR2D( 0, -arm ) );
break;
}
case 2: // underline
aOv->Line( bl, br );
break;
case 3: // rounded rectangle (lines + quarter arcs)
{
if( fill )
{
aOv->SetIsStroke( false );
aOv->Rectangle( tl, br );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
}
double r = std::min( { aS.selCornerPx * aPx, ( br.x - tl.x ) / 2.0,
( br.y - tl.y ) / 2.0 } );
aOv->Line( tl + VECTOR2D( r, 0 ), tr + VECTOR2D( -r, 0 ) );
aOv->Line( bl + VECTOR2D( r, 0 ), br + VECTOR2D( -r, 0 ) );
aOv->Line( tl + VECTOR2D( 0, r ), bl + VECTOR2D( 0, -r ) );
aOv->Line( tr + VECTOR2D( 0, r ), br + VECTOR2D( 0, -r ) );
// Screen-y grows down: the "top-left" corner arc spans 180°→270°.
aOv->Arc( tl + VECTOR2D( r, r ), r, EDA_ANGLE( 180, DEGREES_T ), EDA_ANGLE( 270, DEGREES_T ) );
aOv->Arc( tr + VECTOR2D( -r, r ), r, EDA_ANGLE( 270, DEGREES_T ), EDA_ANGLE( 360, DEGREES_T ) );
aOv->Arc( br + VECTOR2D( -r, -r ), r, EDA_ANGLE( 0, DEGREES_T ), EDA_ANGLE( 90, DEGREES_T ) );
aOv->Arc( bl + VECTOR2D( r, -r ), r, EDA_ANGLE( 90, DEGREES_T ), EDA_ANGLE( 180, DEGREES_T ) );
break;
}
}
drawLabel( aOv, aBox, aName, aColor, aPx, aS );
}
/** Remote cursor (+ name label) in the chosen shape. */
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const std::string& aName,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
double s = aS.cursorSizePx * aPx;
KIGFX::COLOR4D c = aColor.WithAlpha( aS.cursorAlpha );
aOv->SetIsFill( false );
aOv->SetIsStroke( true );
aOv->SetStrokeColor( c );
aOv->SetLineWidth( aS.cursorWidthPx * aPx );
switch( aS.cursorShape )
{
default:
case 0: // cross
aOv->Cross( aPos, KiROUND( s ) );
break;
case 1: // pointer triangle (mouse-arrow-ish, filled)
{
aOv->SetIsFill( true );
aOv->SetFillColor( c );
VECTOR2D pts[3] = { aPos, aPos + VECTOR2D( 0.45 * s * 2, 1.6 * s ),
aPos + VECTOR2D( 1.1 * s, 1.1 * s ) };
aOv->Polygon( pts, 3 );
aOv->SetIsFill( false );
break;
}
case 2: // circle + center dot
aOv->Circle( aPos, s );
aOv->SetIsFill( true );
aOv->SetFillColor( c );
aOv->Circle( aPos, 1.5 * aPx );
aOv->SetIsFill( false );
break;
}
if( aS.cursorLabel && !aName.empty() )
{
double h = aS.cursorLabelSizePx * aPx;
VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx,
( aS.cursorSizePx + 9 ) * aPx );
if( aS.cursorLabelChip )
{
double w = textWidth( aName, h ), padX = 3 * aPx, padY = 2.5 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( 0.92 ) );
aOv->Rectangle( at + VECTOR2D( -padX, -h / 2 - padY ),
at + VECTOR2D( w + padX, h / 2 + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) );
}
else
{
aOv->SetStrokeColor( c );
}
aOv->SetGlyphSize( VECTOR2I( KiROUND( h ), KiROUND( h ) ) );
aOv->BitmapText( wxString::FromUTF8( aName.c_str() ),
VECTOR2I( KiROUND( at.x ), KiROUND( at.y ) ), ANGLE_0 );
}
}
/** Comment pin dot. */
inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor,
bool aResolved, double aPx, const STYLE& aS )
{
double fillAlpha = aResolved ? aS.pinResolvedAlpha : aS.pinFillAlpha;
double ringAlpha = aResolved ? aS.pinRingAlpha * 0.4 : aS.pinRingAlpha;
aOv->SetIsStroke( true );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, ringAlpha ) );
aOv->SetLineWidth( aS.pinRingPx * aPx );
aOv->Circle( aPos, aS.pinRadiusPx * aPx );
}
} // namespace pcbjam_presence
#endif // __EMSCRIPTEN__

View file

@ -54,6 +54,7 @@
#include <sch_sheet_path.h>
#include <schematic_settings.h>
#include <tool/coroutine.h>
#include "collab_presence_style.h"
using namespace emscripten;
using json = nlohmann::json;
@ -706,6 +707,7 @@ struct PEER
struct PIN
{
std::string id;
std::string name; // author (palette-override rehash key)
VECTOR2D pos; // world coords (IU)
KIGFX::COLOR4D color;
bool resolved = false;
@ -713,6 +715,9 @@ struct PIN
std::vector<PEER> g_peers;
std::vector<PIN> g_pins;
// Every visual knob (shapes, widths, alphas, label placement, color overrides)
// — see collab_presence_style.h; live-patched by kicadCollabSetStyle (tuner).
pcbjam_presence::STYLE g_style;
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
bool g_started = false;
bool g_redrawScheduled = false;
@ -800,12 +805,12 @@ void redrawOverlay()
// Screen-constant sizing via the GAL matrix (GetScale() is the zoom, not px/IU).
double px = view->ToWorld( 1.0 );
// All shapes/sizes/placements come from g_style (collab_presence_style.h);
// the drawing itself is shared with pcbnew's TU so the editors never
// diverge visually.
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 );
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, peer.name, peer.color );
for( const KIID& id : peer.selection )
{
@ -815,47 +820,20 @@ void redrawOverlay()
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 );
}
pcbjam_presence::drawSelectionBox( g_overlay.get(), item->ViewBBox(), peer.name,
color, px, g_style );
}
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 );
}
}
pcbjam_presence::drawCursor( g_overlay.get(), peer.cursor, peer.name, color, px,
g_style );
}
// Comment pin dots (0005): filled circle in the author's color with a white
// ring; resolved pins dim. Drawn last so they sit above selection outlines.
// Comment pin dots (0005), drawn last so they sit above selection outlines.
for( const PIN& pin : g_pins )
{
g_overlay->SetIsStroke( true );
g_overlay->SetIsFill( true );
g_overlay->SetFillColor( pin.resolved ? pin.color.WithAlpha( 0.3 ) : pin.color );
g_overlay->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, pin.resolved ? 0.35 : 0.9 ) );
g_overlay->SetLineWidth( 1.5 * px );
g_overlay->Circle( pin.pos, 7 * px );
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, pin.name, pin.color );
pcbjam_presence::drawPin( g_overlay.get(), pin.pos, color, pin.resolved, px, g_style );
}
view->Update( g_overlay.get() );
@ -1624,6 +1602,7 @@ void schCollabSetPins( std::string aJson )
{
presence::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 = presence::parseColor( p.value( "color", "" ) );
pin.resolved = p.value( "resolved", false );
@ -1635,6 +1614,44 @@ void schCollabSetPins( std::string aJson )
presence::scheduleRedraw();
}
// JS → C++ (presence tuner): live-patch the overlay STYLE and repaint —
// see collab_presence_style.h + pcbnew's counterpart.
void schCollabSetStyle( std::string aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
pcbjam_presence::patchStyle( presence::g_style, j );
presence::scheduleRedraw();
}
// Test/tuner helper: the first N item uuids of the CURRENT sheet — real,
// resolvable KIIDs for synthetic remote-selection previews.
std::string schCollabTestListItems( int aCount )
{
SCH_EDIT_FRAME* fr = schFrame();
json out = json::array();
if( fr )
{
if( SCH_SCREEN* screen = currentScreen( fr ) )
{
for( SCH_ITEM* item : screen->Items() )
{
if( (int) out.size() >= aCount )
break;
out.push_back( toUtf8( item->m_Uuid.AsString() ) );
}
}
}
return out.dump();
}
// JS → C++ (0005): pan the view to a world position (comment panel "jump to pin").
void schCollabSetViewport( double aCx, double aCy )
{
@ -1805,6 +1822,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabSetPins", &schCollabSetPins);
function("kicadCollabSetViewport", &schCollabSetViewport);
function("kicadCollabSetStyle", &schCollabSetStyle);
function("kicadCollabTestListItems", &schCollabTestListItems);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);

View file

@ -50,6 +50,8 @@ void pcbCollabPresenceStart();
void pcbCollabSetRemote( std::string aJson );
void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy );
void pcbCollabSetStyle( std::string aJson );
std::string pcbCollabTestListItems( int aCount );
std::string pcbCollabGetViewport();
std::string pcbCollabGetSelection();
std::string pcbCollabTestSelectFirst();
@ -69,6 +71,8 @@ void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy );
void schCollabSetStyle( std::string aJson );
std::string schCollabTestListItems( int aCount );
std::string schCollabGetViewport();
std::string schCollabGetSelection();
std::string schCollabTestSelectFirst();
@ -173,6 +177,16 @@ static void collabSetViewport( double aCx, double aCy )
pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy );
}
static void collabSetStyle( std::string aJson )
{
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson );
}
static std::string collabTestListItems( int aCount )
{
return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount );
}
static std::string collabGetViewport()
{
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
@ -215,6 +229,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport);
function("kicadCollabSetStyle", &collabSetStyle);
function("kicadCollabTestListItems", &collabTestListItems);
function("kicadCollabGetViewport", &collabGetViewport);
function("kicadCollabGetSelection", &collabGetSelection);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);

View file

@ -46,6 +46,7 @@
#include <view/view_overlay.h>
#include <pcb_draw_panel_gal.h>
#include <nlohmann/json.hpp>
#include "collab_presence_style.h"
#include <chrono>
#include <map>
#include <memory>
@ -1112,6 +1113,7 @@ struct PEER
struct PIN
{
std::string id;
std::string name; // author (palette-override rehash key)
VECTOR2D pos; // world coords (IU)
KIGFX::COLOR4D color;
bool resolved = false;
@ -1119,6 +1121,9 @@ struct PIN
std::vector<PEER> g_peers;
std::vector<PIN> g_pins;
// Every visual knob (shapes, widths, alphas, label placement, color overrides)
// — see collab_presence_style.h; live-patched by kicadCollabSetStyle (tuner).
pcbjam_presence::STYLE g_style;
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
bool g_started = false;
bool g_redrawScheduled = false;
@ -1213,12 +1218,12 @@ void redrawOverlay()
// ~7 orders of magnitude (invisible cursors).
double px = view->ToWorld( 1.0 );
// All shapes/sizes/placements come from g_style (collab_presence_style.h);
// the drawing itself is shared with eeschema's TU so the editors never
// diverge visually.
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 );
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, peer.name, peer.color );
for( const KIID& id : peer.selection )
{
@ -1227,48 +1232,20 @@ void redrawOverlay()
if( !item )
continue; // not on this board (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
// (world y grows downward, so "above" is -y).
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 );
}
pcbjam_presence::drawSelectionBox( g_overlay.get(), item->ViewBBox(), peer.name,
color, px, g_style );
}
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 );
}
}
pcbjam_presence::drawCursor( g_overlay.get(), peer.cursor, peer.name, color, px,
g_style );
}
// Comment pin dots (0005): filled circle in the author's color with a white
// ring; resolved pins dim. Drawn last so they sit above selection outlines.
// Comment pin dots (0005), drawn last so they sit above selection outlines.
for( const PIN& pin : g_pins )
{
g_overlay->SetIsStroke( true );
g_overlay->SetIsFill( true );
g_overlay->SetFillColor( pin.resolved ? pin.color.WithAlpha( 0.3 ) : pin.color );
g_overlay->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, pin.resolved ? 0.35 : 0.9 ) );
g_overlay->SetLineWidth( 1.5 * px );
g_overlay->Circle( pin.pos, 7 * px );
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, pin.name, pin.color );
pcbjam_presence::drawPin( g_overlay.get(), pin.pos, color, pin.resolved, px, g_style );
}
view->Update( g_overlay.get() );
@ -1700,6 +1677,7 @@ void pcbCollabSetPins( std::string aJson )
{
presence::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 = presence::parseColor( p.value( "color", "" ) );
pin.resolved = p.value( "resolved", false );
@ -1711,6 +1689,40 @@ void pcbCollabSetPins( std::string aJson )
presence::scheduleRedraw();
}
// JS → C++ (presence tuner): live-patch the overlay STYLE (partial JSON —
// see collab_presence_style.h) and repaint. Dev-time only in practice, but
// harmless in production (nothing calls it without VITE_PRESENCE_TUNER).
void pcbCollabSetStyle( std::string aJson )
{
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( j.is_discarded() )
return;
pcbjam_presence::patchStyle( presence::g_style, j );
presence::scheduleRedraw();
}
// Test/tuner helper: the first N top-level item uuids — real, resolvable KIIDs
// for synthetic remote-selection previews (a solo tab has no peer to borrow from).
std::string pcbCollabTestListItems( int aCount )
{
PCB_EDIT_FRAME* fr = pcbFrame();
json out = json::array();
if( fr )
{
forEachTopItem( *fr->GetBoard(), [&]( BOARD_ITEM* item )
{
if( (int) out.size() < aCount && !item->GetParentFootprint() )
out.push_back( toUtf8( item->m_Uuid.AsString() ) );
} );
}
return out.dump();
}
// JS → C++ (0005): pan the view to a world position (comment panel "jump to
// pin"). CallAfter + COROUTINE like every other view mutation from JS.
void pcbCollabSetViewport( double aCx, double aCy )
@ -2063,6 +2075,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabSetRemote", &pcbCollabSetRemote);
function("kicadCollabSetPins", &pcbCollabSetPins);
function("kicadCollabSetViewport", &pcbCollabSetViewport);
function("kicadCollabSetStyle", &pcbCollabSetStyle);
function("kicadCollabTestListItems", &pcbCollabTestListItems);
function("kicadCollabGetViewport", &pcbCollabGetViewport);
function("kicadCollabGetSelection", &pcbCollabGetSelection);
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);