Editor/Dock/CollaborationDock.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;
using Sandbox.Internal;
using Sandbox.UI;
namespace TeamCreate;
[Dock("Editor", "Collaboration", "groups", DockArea.Bottom)]
public sealed class CollaborationDock : Widget
{
private sealed class ContentCanvas : Widget
{
public ContentCanvas(Widget parent)
: base(parent)
{
}
public Vector2 WantedSize()
{
return SizeHint();
}
public float MinimumNeeded()
{
return MinimumSizeHint().y;
}
public void Reflow()
{
DockTheme.InvalidateLayout(this);
UpdateGeometry();
}
protected override void OnResize()
{
base.OnResize();
DockTheme.InvalidateLayout(this);
}
}
private sealed class HeaderBar : Widget
{
public HeaderBar(Widget parent)
: base(parent)
{
FixedHeight = 30f;
OnPaintOverride = PaintHeader;
}
private bool PaintHeader()
{
CollaborationSession current = CollaborationSession.Current;
SessionState state = current.State;
Color color = DockTheme.StateColor(state);
string text = DockTheme.StateVerb(state);
Rect localRect = LocalRect;
float width = 30f + Paint.MeasureText(text).x + 14f;
Rect rect = new Rect(localRect.Left, localRect.Top + 3f, in width, localRect.Height - 6f);
Paint.SetBrushAndPen(color.WithAlpha(0.16f), color.WithAlpha(0.55f));
Paint.DrawRect(in rect, rect.Height * 0.5f);
Paint.SetBrush(in color);
Paint.ClearPen();
Paint.DrawCircle(new Vector2(rect.Left + 12f, rect.Top + rect.Height * 0.5f), 4.0);
Paint.SetPen(in Theme.Text, 0f, PenStyle.Solid);
Paint.DrawText(new Rect(rect.Left + 22f, rect.Top, width - 26f, rect.Height), text, TextFlag.LeftCenter | TextFlag.SingleLine);
string text2 = current.OrganizationDirectory?.Org ?? current.Config?.Org;
string text3 = "org " + (string.IsNullOrWhiteSpace(text2) ? "unknown" : text2);
string text4 = "library " + LibraryInfo.LibraryVersion;
float width2 = Paint.MeasureText(text4).x + 16f;
float width3 = Paint.MeasureText(text3).x + 16f;
Rect rect2 = new Rect(localRect.Right - width2, rect.Top, in width2, rect.Height);
Rect rect3 = new Rect(rect2.Left - 6f - width3, rect.Top, in width3, rect.Height);
Paint.SetBrushAndPen(Theme.ControlBackground, Theme.Text.WithAlpha(0.12f));
Paint.DrawRect(in rect3, 4f);
Paint.DrawRect(in rect2, 4f);
Paint.SetPen(Theme.Text.WithAlpha(0.75f), 0f, PenStyle.Solid);
Paint.DrawText(in rect3, text3, TextFlag.Center | TextFlag.SingleLine);
Paint.DrawText(in rect2, text4, TextFlag.Center | TextFlag.SingleLine);
return false;
}
}
public const string FooterUndoLine = "While connected, use collaboration Undo. Each commit clears the native undo history and replaces it with one Collaboration Undo entry, so the Undo dock and Ctrl+Z act on your most recent collaboration operation only. Save only when synchronized. Identity is the signed-in Steam account.";
public const string FooterTransportLine = "Invite-code and discovery sessions are encrypted; Advanced sessions opened with a typed key are not.";
public const string RuntimeParagraph = "Runtime collaboration is experimental and opt-in only. It currently coordinates lifecycle state only; runtime transport, sampling, undo, apply, runtime hotload data flow, and synchronization are unsupported. Hotload lifecycle blocking, recovery archival, and transport disposal are supported. Source collaboration remains the default.";
public const string LimitsParagraph = "There is no public matchmaking and no NAT hole punching: friends on different networks need the project's relay, or a forwarded port on the host. While you play, scene edits are paused and a collaborator cannot watch the running game. Full-scene scanning, bounded 8 MiB packets, queues of 256 packets and a host limit of 16 connections suit small experimental sessions. Large scenes, high latency, large teams and long-running memory/journal growth have not been qualified. Native undo history carries only the most recent collaboration operation, and redo restores that same operation — there is no longer undo or redo history behind it. See the library README, Explicit limits, for the full list.";
private Editor.Label statusDetail;
private Editor.Label setupBanner;
private Editor.Label undoHint;
private ReadinessCard readiness;
private ConnectCard connect;
private RelayCard relay;
private OrgRosterCard orgRoster;
private SessionRosterCard sessionRoster;
private ControlsCard controls;
private SoloTestCard soloTest;
private Editor.Button aboutToggle;
private Widget aboutBody;
private SegmentedControl tabs;
private readonly Dictionary<DockTab, ScrollArea> scrollers = new Dictionary<DockTab, ScrollArea>();
private readonly Dictionary<DockTab, ContentCanvas> pages = new Dictionary<DockTab, ContentCanvas>();
private DockTab selectedTab = DockTab.Session;
private DockTab? appliedTab;
private bool tabChosenByPerson;
private DateTime nextBuildAttempt;
private Widget lastParent;
private Vector2 lastSize;
private Vector2 lastPosition;
private bool reflowing;
private int layoutReflowCount;
private const string TabCookie = "collaboration.dock.tab";
private ScrollArea scroller => scrollers.TryGetValue(selectedTab, out ScrollArea value) ? value : null;
private ContentCanvas content => pages.TryGetValue(selectedTab, out ContentCanvas value) ? value : null;
private bool Built => statusDetail != null && setupBanner != null && undoHint != null && readiness != null && connect != null && relay != null && orgRoster != null && sessionRoster != null && controls != null && soloTest != null
&& aboutToggle != null && aboutBody != null && tabs != null && scrollers.Count == DockSectionRules.All.Length && pages.Count == DockSectionRules.All.Length
&& DockSectionRules.All.All(tab => scrollers[tab] != null && scrollers[tab].Canvas == pages[tab])
&& readiness.Complete && connect.Complete && relay.Complete && orgRoster.Complete && sessionRoster.Complete && controls.Complete && soloTest.Complete;
public CollaborationDock(Widget parent)
: base(parent)
{
Layout = Layout.Column();
Layout.Margin = 12f;
Layout.Spacing = 8f;
Build();
}
private void Build()
{
Layout.Clear(deleteWidgets: true);
scrollers.Clear();
pages.Clear();
Layout.Add(new HeaderBar(this));
statusDetail = Layout.Add(new Editor.Label("", this)
{
WordWrap = true,
Color = DockTheme.MutedColor
});
// The native segmented control, the same one the engine's own pages use for Global / Current Scene: themed, with icons, and it collapses to icons when narrow.
tabs = Layout.Add(new SegmentedControl(this));
foreach (DockTab tab in DockSectionRules.All)
{
tabs.AddOption(DockSectionRules.Name(tab), DockSectionRules.Icon(tab));
}
tabs.ToolTip = "Session: host or join, save, undo. People: who is here. Setup: readiness, relay and the solo test.";
// One scroll area per section, so each keeps its own scroll position and nothing the person is not looking at is laid out.
foreach (DockTab tab in DockSectionRules.All)
{
var area = Layout.Add(new ScrollArea(this), 1);
area.VerticalSizeMode = SizeMode.Default;
area.VerticalScrollbarMode = ScrollbarMode.Auto;
area.HorizontalScrollbarMode = ScrollbarMode.Off;
var page = new ContentCanvas(area);
area.Canvas = page;
page.SetSizeMode(SizeMode.Default, SizeMode.CanGrow);
page.Layout = Layout.Column();
page.Layout.Spacing = 8f;
page.Layout.SizeConstraint = SizeConstraint.SetMinimumSize;
page.ContentMargins = new Margin(0f, 0f, 10f, 0f);
area.Visible = false;
scrollers[tab] = area;
pages[tab] = page;
}
// ---- Session: run a session ----
Layout session = pages[DockTab.Session].Layout;
setupBanner = session.Add(new Editor.Label("", pages[DockTab.Session])
{
WordWrap = true,
Color = DockTheme.WarningColor
});
setupBanner.Visible = false;
connect = session.Add(new ConnectCard(pages[DockTab.Session]));
controls = session.Add(new ControlsCard(pages[DockTab.Session], connect));
undoHint = session.Add(new Editor.Label("While connected, use collaboration Undo: each commit clears the native undo history and leaves one Collaboration Undo entry, so Ctrl+Z acts on your most recent collaboration operation only.", pages[DockTab.Session])
{
WordWrap = true,
Color = DockTheme.MutedColor
});
session.AddStretchCell(1);
// ---- People: who is here ----
Layout people = pages[DockTab.People].Layout;
sessionRoster = people.Add(new SessionRosterCard(pages[DockTab.People]));
orgRoster = people.Add(new OrgRosterCard(pages[DockTab.People]));
people.AddStretchCell(1);
// ---- Setup: get ready ----
Layout setup = pages[DockTab.Setup].Layout;
bool soloCopy = SoloTestRunner.IsSoloCopy;
if (soloCopy)
{
soloTest = setup.Add(new SoloTestCard(pages[DockTab.Setup]));
}
readiness = setup.Add(new ReadinessCard(pages[DockTab.Setup]));
relay = setup.Add(new RelayCard(pages[DockTab.Setup]));
if (!soloCopy)
{
soloTest = setup.Add(new SoloTestCard(pages[DockTab.Setup]));
}
aboutToggle = setup.Add(new Editor.Button("▸ About & limits", pages[DockTab.Setup])
{
ToolTip = "What this library does, and what it deliberately does not do."
});
aboutToggle.Clicked = ToggleAbout;
aboutBody = setup.Add(new Widget(pages[DockTab.Setup]));
aboutBody.Layout = Layout.Column();
aboutBody.Layout.Spacing = 8f;
aboutBody.Layout.Add(new Editor.Label("Runtime collaboration is experimental and opt-in only. It currently coordinates lifecycle state only; runtime transport, sampling, undo, apply, runtime hotload data flow, and synchronization are unsupported. Hotload lifecycle blocking, recovery archival, and transport disposal are supported. Source collaboration remains the default.", pages[DockTab.Setup])
{
WordWrap = true,
Color = DockTheme.MutedColor
});
aboutBody.Layout.Add(new Editor.Label(LimitsParagraph, pages[DockTab.Setup])
{
WordWrap = true,
Color = DockTheme.MutedColor
});
aboutBody.Layout.Add(new Editor.Label(FooterTransportLine, pages[DockTab.Setup])
{
WordWrap = true,
Color = DockTheme.MutedColor
});
aboutBody.Visible = false;
setup.AddStretchCell(1);
// Which section to open on: the last one the person chose, except that a solo test copy always opens on Setup, where its checklist is.
int saved = Sandbox.Internal.GlobalToolsNamespace.EditorCookie.Get(TabCookie, -1);
selectedTab = DockSectionRules.Initial(soloCopy, saved, CollaborationSession.Current.Readiness == null || ReadinessRules.CanStart(CollaborationSession.Current.Readiness));
appliedTab = null;
tabChosenByPerson = false;
tabs.SelectedIndex = DockSectionRules.IndexOf(selectedTab);
tabs.OnSelectedChanged = name => ChooseTab(name);
ApplyTab();
Reflow();
}
private void ChooseTab(string name)
{
foreach (DockTab tab in DockSectionRules.All)
{
if (string.Equals(DockSectionRules.Name(tab), name, StringComparison.Ordinal))
{
selectedTab = tab;
tabChosenByPerson = true;
if (!SoloTestRunner.IsSoloCopy)
{
Sandbox.Internal.GlobalToolsNamespace.EditorCookie.Set(TabCookie, DockSectionRules.IndexOf(tab));
}
ApplyTab();
return;
}
}
}
/// <summary>Shows the current section's scroll area and hides the others. Only the visible one is laid out.</summary>
private void ApplyTab()
{
if (appliedTab == selectedTab)
{
return;
}
appliedTab = selectedTab;
foreach (DockTab tab in DockSectionRules.All)
{
if (scrollers.TryGetValue(tab, out ScrollArea area) && area != null)
{
area.Visible = tab == selectedTab;
}
}
int index = DockSectionRules.IndexOf(selectedTab);
if (tabs != null && tabs.SelectedIndex != index)
{
tabs.SelectedIndex = index;
}
Reflow();
}
private void ToggleAbout()
{
aboutBody.Visible = !aboutBody.Visible;
DockTheme.SetText(aboutToggle, aboutBody.Visible ? "▾ About & limits" : "▸ About & limits");
content?.Reflow();
}
[Shortcut("collab.save", "CTRL+ALT+S", ShortcutType.Window)]
public void SaveShortcut()
{
if (Built)
{
RunShortcut(() =>
{
CollaborationSession.Current.Save();
});
}
}
[Shortcut("collab.undo", "CTRL+ALT+Z", ShortcutType.Window)]
public void UndoShortcut()
{
if (Built)
{
RunShortcut(() =>
{
CollaborationSession.Current.Undo();
});
}
}
[Shortcut("collab.recover", "CTRL+ALT+R", ShortcutType.Window)]
public void RecoverShortcut()
{
if (Built)
{
RunShortcut(() =>
{
CollaborationSession.Current.Reconnect(connect.Address, connect.PortNumber);
});
}
}
private static void RunShortcut(Action action)
{
try
{
action();
}
catch (Exception error)
{
CollaborationSession.Current.ReportAction(error);
}
}
[EditorEvent.Frame]
public void Refresh()
{
if (!Built)
{
if (DateTime.UtcNow < nextBuildAttempt)
{
return;
}
try
{
Build();
}
catch (Exception ex)
{
GlobalGameNamespace.Log.Error("Collaboration dock: " + CollaborationDiagnostics.Bounded(ex.Message));
}
if (!Built)
{
nextBuildAttempt = DateTime.UtcNow.AddSeconds(5.0);
return;
}
}
Vector2 size = Size;
Vector2 position = Position;
Widget parent = Parent;
if (size != lastSize || position != lastPosition || parent != lastParent)
{
lastSize = size;
lastPosition = position;
lastParent = parent;
Reflow();
}
CollaborationSession current = CollaborationSession.Current;
AvatarCache.Poll();
DockTheme.SetText(statusDetail, current.Status);
readiness.Refresh();
connect.Refresh();
relay.Refresh();
orgRoster.Refresh();
sessionRoster.Refresh();
controls.Refresh();
soloTest?.Refresh();
RefreshSections(current);
}
private bool? lastActionsShown;
private bool? lastRosterShown;
private string lastBanner;
/// <summary>
/// Decides, from the session's state, what each section shows. Everything is compared before it is written, so a frame in which nothing changed touches nothing.
/// </summary>
private void RefreshSections(CollaborationSession session)
{
bool inSession = session.State != SessionState.Offline && session.State != SessionState.Ready;
// Save, Undo, Recover and their options: only when there is a session to act on, or something just happened that needs reading.
bool actions = DockSectionRules.ShowActions(inSession, controls.HasMessage);
if (lastActionsShown != actions)
{
lastActionsShown = actions;
controls.Visible = actions;
undoHint.Visible = actions;
Reflow();
}
bool roster = DockSectionRules.ShowSessionRoster(inSession);
if (lastRosterShown != roster)
{
lastRosterShown = roster;
sessionRoster.Visible = roster;
Reflow();
}
// Why hosting is blocked, at the top of the tab where the person would press Host.
string banner = DockSectionRules.SetupBanner(session.Readiness, inSession);
if (!string.Equals(banner, lastBanner, StringComparison.Ordinal))
{
lastBanner = banner;
DockTheme.SetText(setupBanner, banner ?? string.Empty);
setupBanner.Visible = banner != null;
Reflow();
}
// The project folder is not always known on the first frame, so a solo test copy is recognised here as well as when the dock is built: it opens on Setup, where its checklist is.
if (!tabChosenByPerson && SoloTestRunner.IsSoloCopy && selectedTab != DockTab.Setup)
{
selectedTab = DockTab.Setup;
ApplyTab();
}
// A person who has not chosen a tab is taken where the action is: to Setup if hosting is blocked, to Session once it is possible. Their own choice is never overridden.
if (!tabChosenByPerson && !SoloTestRunner.IsSoloCopy && Sandbox.Internal.GlobalToolsNamespace.EditorCookie.Get(TabCookie, -1) < 0)
{
DockTab wanted = DockSectionRules.Default(false, session.Readiness == null || session.Readiness.Count == 0 || ReadinessRules.CanStart(session.Readiness));
if (wanted != selectedTab)
{
selectedTab = wanted;
ApplyTab();
}
}
}
private static bool IsTooShort(Widget widget)
{
if (widget == null)
{
return false;
}
if (ContentBottom(widget) > widget.Height + 0.5f)
{
return true;
}
if (widget is DockCard dockCard)
{
return dockCard.MinimumNeeded() > widget.Height + 0.5f;
}
if (widget is ContentCanvas contentCanvas)
{
return contentCanvas.MinimumNeeded() > widget.Height + 0.5f;
}
return false;
}
private void Reflow()
{
if (reflowing)
{
return;
}
reflowing = true;
try
{
layoutReflowCount++;
DockTheme.InvalidateLayout(this);
foreach (var item2 in VisibleCards())
{
DockCard item = item2.Card;
DockTheme.InvalidateLayout(item);
}
content?.Reflow();
}
finally
{
reflowing = false;
}
}
protected override void OnResize()
{
base.OnResize();
Reflow();
}
protected override void OnMoved()
{
base.OnMoved();
Reflow();
}
public string LayoutDiagnostic()
{
List<string> list = new List<string>();
int num = 0;
if (content == null || scroller == null)
{
return "state=faulty; reason=not-built; reflows=" + layoutReflowCount;
}
if (IsTooShort(content))
{
list.Add("column");
}
num += CountOverlaps(content);
foreach (var (item, dockCard) in VisibleCards())
{
if (dockCard != null)
{
if (IsTooShort(dockCard))
{
list.Add(item);
}
num += CountOverlaps(dockCard);
}
}
num += CountOverlaps(this);
Vector2 vector = content.WantedSize();
StringBuilder stringBuilder = new StringBuilder(384);
AppendCardDetail(stringBuilder, "column", vector.y, content.Height, content.Width, ContentBottom(content), content.MinimumNeeded());
foreach (var (text, dockCard2) in VisibleCards())
{
if (dockCard2 != null)
{
bool flag = list.Contains(text);
AppendCardDetail(stringBuilder, text, dockCard2.WantedSize().y, dockCard2.Height, dockCard2.Width, ContentBottom(dockCard2), dockCard2.MinimumNeeded());
if (flag)
{
AppendChildDetail(stringBuilder, dockCard2);
}
}
}
StringBuilder stringBuilder2 = new StringBuilder(512);
stringBuilder2.Append((list.Count == 0 && num == 0) ? "state=ok" : "state=faulty");
stringBuilder2.Append("; tab=").Append(DockSectionRules.Name(selectedTab));
stringBuilder2.Append("; reflows=").Append(layoutReflowCount);
stringBuilder2.Append("; dock=").Append(Size.x.ToString("0")).Append('x')
.Append(Size.y.ToString("0"));
stringBuilder2.Append("; viewport=").Append(scroller.Width.ToString("0")).Append('x')
.Append(scroller.Height.ToString("0"));
stringBuilder2.Append("; column=").Append(content.Height.ToString("0"));
stringBuilder2.Append("; columnWanted=").Append(vector.y.ToString("0"));
stringBuilder2.Append("; scroll=").Append((scroller.Canvas == content) ? "on" : "off");
stringBuilder2.Append("; compressed=").Append((list.Count == 0) ? "none" : string.Join(",", list));
stringBuilder2.Append("; overlaps=").Append(num);
stringBuilder2.Append("; detail=").Append(stringBuilder);
return stringBuilder2.ToString();
}
private static void AppendCardDetail(StringBuilder builder, string name, float want, float height, float width, float fit, float? minimum = null)
{
if (builder.Length > 0)
{
builder.Append('|');
}
builder.Append(name).Append(":want=").Append(want.ToString("0"))
.Append(",h=")
.Append(height.ToString("0"))
.Append(",w=")
.Append(width.ToString("0"))
.Append(",fit=")
.Append(fit.ToString("0"));
if (minimum.HasValue)
{
builder.Append(",min=").Append(minimum.Value.ToString("0"));
}
}
private static void AppendChildDetail(StringBuilder builder, Widget card)
{
builder.Append(",kids=");
bool flag = true;
foreach (Widget child in card.Children)
{
if (child != null && child.Visible)
{
if (!flag)
{
builder.Append(';');
}
flag = false;
builder.Append(child.GetType().Name).Append(':').Append(child.Position.y.ToString("0"))
.Append('/')
.Append(child.Height.ToString("0"));
}
}
}
private static float ContentBottom(Widget parent)
{
float num = 0f;
foreach (Widget child in parent.Children)
{
if (child != null && child.Visible)
{
float num2 = child.Position.y + child.Height;
if (num2 > num)
{
num = num2;
}
}
}
return num;
}
/// <summary>Every card with the section it lives in. A hidden card has no size, so the layout check and the reflow use only the ones on the visible section.</summary>
private IEnumerable<(string Name, DockCard Card)> Cards()
{
yield return (Name: "connect", Card: connect);
yield return (Name: "controls", Card: controls);
yield return (Name: "sessionRoster", Card: sessionRoster);
yield return (Name: "orgRoster", Card: orgRoster);
yield return (Name: "readiness", Card: readiness);
yield return (Name: "relay", Card: relay);
yield return (Name: "soloTest", Card: soloTest);
}
private static DockTab SectionOf(string card)
{
switch (card)
{
case "connect":
case "controls":
return DockTab.Session;
case "sessionRoster":
case "orgRoster":
return DockTab.People;
default:
return DockTab.Setup;
}
}
private IEnumerable<(string Name, DockCard Card)> VisibleCards()
{
foreach (var item in Cards())
{
if (item.Card != null && item.Card.Visible && SectionOf(item.Name) == selectedTab)
{
yield return item;
}
}
}
private static int CountOverlaps(Widget parent)
{
List<Widget> list = new List<Widget>();
foreach (Widget child in parent.Children)
{
if (child != null && child.Visible)
{
list.Add(child);
}
}
if (list.Count < 2)
{
return 0;
}
list.Sort((Widget a, Widget b) => a.Position.y.CompareTo(b.Position.y));
int num = 0;
for (int num2 = 1; num2 < list.Count; num2++)
{
Widget widget = list[num2 - 1];
Widget widget2 = list[num2];
if (!(MathF.Abs(widget.Position.x - widget2.Position.x) > 0.5f) && widget.Position.y + widget.Height > widget2.Position.y + 0.5f)
{
num++;
}
}
return num;
}
}