diff --git a/wasm/bindings/collab_presence_style.h b/wasm/bindings/collab_presence_style.h new file mode 100644 index 0000000..a377663 --- /dev/null +++ b/wasm/bindings/collab_presence_style.h @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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() ); + } + } + + 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__ diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 5512438..2b57961 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -54,6 +54,7 @@ #include #include #include +#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 g_peers; std::vector 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 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); diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 0a5ea50..ece5218 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -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); diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index cb53576..fcbd425 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -46,6 +46,7 @@ #include #include #include +#include "collab_presence_style.h" #include #include #include @@ -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 g_peers; std::vector 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 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); diff --git a/web/standalone/src/components/PresenceTuner.tsx b/web/standalone/src/components/PresenceTuner.tsx new file mode 100644 index 0000000..e592ebe --- /dev/null +++ b/web/standalone/src/components/PresenceTuner.tsx @@ -0,0 +1,374 @@ +import * as React from "react"; +import { PRESENCE_COLORS } from "@pcbjam/shared"; +import { Palette, X } from "lucide-react"; + +/** + * DEV-TIME presence style tuner (VITE_PRESENCE_TUNER=1): live-patches the wasm + * overlay style via kicadCollabSetStyle so we can try shapes, widths, alphas, + * label placement, palettes etc. and pick the shipped defaults. Settings + * persist in localStorage across reloads; "Copy JSON" exports the current + * values to wire into collab_presence_style.h. "Demo peers/pins" injects + * synthetic remote state (kicadCollabSetRemote/SetPins) so a SOLO tab can + * preview everything — note a real awareness/comment change overwrites the + * synthetic snapshot (re-toggle to restore). + */ + +export interface TunerModule { + kicadCollabSetStyle(json: string): void; + kicadCollabSetRemote(json: string): void; + kicadCollabSetPins(json: string): void; + kicadCollabGetViewport(): string; + kicadCollabTestListItems(n: number): string; +} + +export function hasTunerBridge(mod: unknown): mod is TunerModule { + const m = mod as Partial | undefined; + return ( + typeof m?.kicadCollabSetStyle === "function" && + typeof m?.kicadCollabTestListItems === "function" + ); +} + +/** Mirror of collab_presence_style.h STYLE — defaults MUST match the C++. */ +const DEFAULT_STYLE = { + selShape: 0, + selStrokeWidth: 2.5, + selStrokeAlpha: 0.9, + selFillAlpha: 0, + selPaddingPx: 4, + selCornerPx: 8, + labelShow: true, + labelSizePx: 9, + labelChip: false, + labelVPos: 0, + labelHPos: 0, + labelInside: false, + labelOffsetPx: 8, + cursorShape: 0, + cursorSizePx: 7, + cursorWidthPx: 2, + cursorAlpha: 0.9, + cursorLabel: true, + cursorLabelSizePx: 10, + cursorLabelChip: false, + fixedColor: "", + palette: [] as string[], + pinRadiusPx: 7, + pinRingPx: 1.5, + pinRingAlpha: 0.9, + pinFillAlpha: 1, + pinResolvedAlpha: 0.3, +}; + +type Style = typeof DEFAULT_STYLE; + +const STORE_KEY = "pcbjam:presence-style"; + +function loadStored(): Style { + try { + const raw = localStorage.getItem(STORE_KEY); + return raw ? { ...DEFAULT_STYLE, ...JSON.parse(raw) } : { ...DEFAULT_STYLE }; + } catch { + return { ...DEFAULT_STYLE }; + } +} + +const SEL_SHAPES = ["rectangle", "corner brackets", "underline", "rounded rect", "filled only"]; +const CURSOR_SHAPES = ["cross", "pointer", "circle + dot"]; +const VPOS = ["top", "bottom"]; +const HPOS = ["start", "end", "center"]; + +export function PresenceTuner({ mod }: { mod: TunerModule }) { + const [open, setOpen] = React.useState(true); + const [style, setStyle] = React.useState
+
+ + Presence tuner (dev) + + +
+ +
+ + + +
+ +
+
+ set("labelVPos", v)} /> + set("cursorShape", v)} /> + set("cursorSizePx", v)} /> + set("cursorWidthPx", v)} /> + set("cursorAlpha", v)} /> + set("cursorLabel", v)} /> + set("cursorLabelSizePx", v)} /> + set("cursorLabelChip", v)} /> +
+ +
+
+ fixed color + set("fixedColor", e.target.value)} + className="h-5 w-8" + /> + + {style.fixedColor || "per-user"} +
+
+ palette override (hex, comma-sep) +