Code/Internal/StateController.cs
using System.Text;
using System.Threading;
using Sandbox;
using Sandbox.UI;
namespace Goo.Internal;
internal sealed class StateController
{
static int _nextKey;
readonly Panel _host;
readonly string _key;
StyleSheet? _sheet;
public StateController(Panel host)
{
_host = host;
_key = $"goo-v-{Interlocked.Increment(ref _nextKey)}";
_host.AddClass(_key);
}
public void ApplyVariants(
Color? baseBg, Color? baseFg,
Color? hoverBg, Color? activeBg, Color? focusBg,
Color? hoverFg, Color? activeFg, Color? focusFg,
int? transitionMs)
{
// Engine routes input to the nearest Panel whose ComputedStyle.PointerEvents is All
// (Panel.WantsMouseInput). Without this the :hover/:active pseudoclasses never flip.
_host.Style.PointerEvents = PointerEvents.All;
if (focusBg.HasValue || focusFg.HasValue)
_host.AcceptsFocus = true;
var css = BuildCss(
_key,
baseBg, baseFg,
hoverBg, activeBg, focusBg,
hoverFg, activeFg, focusFg,
transitionMs);
// Remove-by-instance sidesteps the engine Parse(string) leak: StyleSheet.FromString
// never sets FileName, so the built-in Remove("string") wildcard no-ops and sheets
// accumulate forever, producing nondeterministic cascade.
if (_sheet is not null) _host.StyleSheet.Remove(_sheet);
_sheet = StyleSheet.FromString(css, "goo-state-variants");
_host.StyleSheet.Add(_sheet);
}
internal static string BuildCss(
string key,
Color? baseBg, Color? baseFg,
Color? hoverBg, Color? activeBg, Color? focusBg,
Color? hoverFg, Color? activeFg, Color? focusFg,
int? transitionMs)
{
var sb = new StringBuilder();
// Source order is the tiebreaker for same-specificity selectors: later wins.
// Emitting base, :focus, :hover, :active gives Active > Hover > Focus > Base.
AppendRule(sb, $".{key}", baseBg, baseFg, transitionMs);
AppendRule(sb, $".{key}:focus", focusBg, focusFg, null);
AppendRule(sb, $".{key}:hover", hoverBg, hoverFg, null);
AppendRule(sb, $".{key}:active", activeBg, activeFg, null);
return sb.ToString();
}
static void AppendRule(StringBuilder sb, string selector, Color? bg, Color? fg, int? transitionMs)
{
bool hasTransition = transitionMs is int ms0 && ms0 > 0;
if (bg is null && fg is null && !hasTransition) return;
sb.Append(selector).Append(" { ");
if (bg.HasValue) sb.Append("background-color: ").Append(CssColor(bg.Value)).Append("; ");
if (fg.HasValue) sb.Append("color: ").Append(CssColor(fg.Value)).Append("; ");
if (hasTransition)
{
int ms = transitionMs!.Value;
sb.Append("transition: background-color ").Append(ms).Append("ms, color ").Append(ms).Append("ms; ");
}
sb.Append("}\n");
}
static string CssColor(Color c)
{
int r = (int)(c.r * 255);
int g = (int)(c.g * 255);
int b = (int)(c.b * 255);
return $"rgba({r},{g},{b},{c.a:0.###})";
}
}