using System; using System.Collections.Generic; using O5Kit.Behaviour; using O5Kit.Core; using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace ADOFAIRenderer.UI; /// /// Definition of a single option within a . /// Supports static strings, dynamic providers, or localization keys. /// internal class SegmentOption { public T Value { get; set; } public string? LabelKey { get; set; } public Func? DynamicLabel { get; set; } public string? StaticLabel { get; set; } public SegmentOption(T value, string labelKey) { Value = value; LabelKey = labelKey; } public SegmentOption(T value, Func dynamicLabel) { Value = value; DynamicLabel = dynamicLabel; } public SegmentOption(T value, string? labelKey = null, string? staticLabel = null) { Value = value; LabelKey = labelKey; StaticLabel = staticLabel; } public static implicit operator SegmentOption((T value, string labelKey) tuple) => new(tuple.value, tuple.labelKey); } /// /// A generalized rounded segmented button group / picker UI component. /// Visual styling, colors, and dimensions are pulled directly from (matching ). /// Automatically updates localized labels via . /// internal class SegmentedControl : O5Object { private readonly List _items = new(); private T _value; private bool _blocked; public T Value => _value; public T DefaultValue { get; } public IReadOnlyList> Options { get; } public Action? OnChanged { get; set; } /// Resting background color for unselected segments (defaults to Theme.ObjectButton). public Color NormalColor { get; set; } /// Resting background color for the selected segment (defaults to Theme.ObjectActive). public Color ActiveColor { get; set; } /// Border and outline color (defaults to Theme.ControlOutlineIdle). public Color BorderColor { get; set; } private class SegmentItem { public SegmentOption Option { get; } public GameObject RootObject { get; } public Image Background { get; } public TextMeshProUGUI Label { get; } public ITweenHandle? HoverTween { get; set; } public bool Hovered { get; set; } public bool Pressed { get; set; } public SegmentItem(SegmentOption option, GameObject root, Image bg, TextMeshProUGUI label) { Option = option; RootObject = root; Background = bg; Label = label; } public void Tint(O5Context ctx, Color target, float duration) { HoverTween?.Kill(); var bg = Background; HoverTween = ctx.Tween.TweenColor(() => bg.color, v => bg.color = v, target, duration); } } public SegmentedControl( O5Context ctx, string id, RectTransform rect, T defaultValue, T initialValue, IReadOnlyList> options, Action? onChanged = null) : base(ctx, id, rect) { DefaultValue = defaultValue; _value = initialValue; Options = options; OnChanged = onChanged; // Pull colors directly from O5Theme, identical to O5Button / O5Toggle NormalColor = Ctx.Theme.ObjectButton; ActiveColor = Ctx.Theme.ObjectActive; BorderColor = Ctx.Theme.ControlOutlineIdle; BuildSegments(); UpdateVisual(true); } /// /// Creates and attaches a new rounded to . /// Height matches standard buttons and sliders (). /// public static SegmentedControl Create( O5Context ctx, Transform parent, string id, T defaultValue, T initialValue, IReadOnlyList> options, float? height = null, Action? onChanged = null) { var h = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody); var controlObj = new GameObject($"SegmentedControl_{id}"); controlObj.transform.SetParent(parent, false); var rect = controlObj.AddComponent(); var le = controlObj.AddComponent(); le.minHeight = h; le.preferredHeight = h; le.flexibleWidth = 1f; return new SegmentedControl(ctx, id, rect, defaultValue, initialValue, options, onChanged); } private void BuildSegments() { var theme = Ctx.Theme; // 1. Masked container for rounded corners var maskObj = new GameObject("MaskContainer"); maskObj.transform.SetParent(Rect, false); var maskRect = maskObj.AddComponent(); maskRect.anchorMin = Vector2.zero; maskRect.anchorMax = Vector2.one; maskRect.offsetMin = Vector2.zero; maskRect.offsetMax = Vector2.zero; var maskImg = maskObj.AddComponent(); maskImg.sprite = Ctx.Sprites.RoundedControl; maskImg.type = Image.Type.Sliced; maskImg.color = NormalColor; var mask = maskObj.AddComponent(); mask.showMaskGraphic = true; // Continuous horizontal layout with no middle divider line var layout = maskObj.AddComponent(); layout.padding = new RectOffset(0, 0, 0, 0); layout.spacing = 0f; layout.childControlWidth = true; layout.childControlHeight = true; layout.childForceExpandWidth = true; layout.childForceExpandHeight = true; for (var i = 0; i < Options.Count; i++) { var opt = Options[i]; var segmentObj = new GameObject($"Segment_{i}_{opt.Value}"); segmentObj.transform.SetParent(maskRect, false); var segRect = segmentObj.AddComponent(); // ReSharper disable once InconsistentNaming var segLE = segmentObj.AddComponent(); segLE.minWidth = 0f; segLE.preferredWidth = 0f; segLE.flexibleWidth = 1f; var bgImage = segmentObj.AddComponent(); bgImage.color = NormalColor; bgImage.raycastTarget = true; var textObj = new GameObject("Text"); textObj.transform.SetParent(segmentObj.transform, false); var textRect = textObj.AddComponent(); textRect.anchorMin = Vector2.zero; textRect.anchorMax = Vector2.one; textRect.offsetMin = Vector2.zero; textRect.offsetMax = Vector2.zero; var tmp = textObj.AddComponent(); tmp.font = Ctx.Fonts.Regular; tmp.fontSize = theme.FontSizeBody; tmp.alignment = TextAlignmentOptions.Center; tmp.characterSpacing = -1f; tmp.raycastTarget = false; // Bind localization if (!string.IsNullOrEmpty(opt.LabelKey)) { LocalizedText.Bind(tmp, opt.LabelKey); } else if (opt.DynamicLabel != null) { LocalizedText.Bind(tmp, opt.DynamicLabel); } else { tmp.text = opt.StaticLabel ?? opt.Value?.ToString() ?? string.Empty; } var item = new SegmentItem(opt, segmentObj, bgImage, tmp); _items.Add(item); // Wire pointer events like O5Button var trigger = segmentObj.AddComponent(); UnityUtils.AddEvents(trigger, (EventTriggerType.PointerEnter, _ => OnSegmentHoverEnter(item)), (EventTriggerType.PointerExit, _ => OnSegmentHoverExit(item)), (EventTriggerType.PointerDown, e => { if (IsLeftPress(e)) { OnSegmentPressEnter(item); } }), (EventTriggerType.PointerUp, _ => OnSegmentPressExit(item)), (EventTriggerType.PointerClick, e => { if (IsLeftPress(e)) { SelectOption(item.Option.Value); } }) ); } // 2. Rounded outline overlay on top of masked container var outlineObj = new GameObject("Outline"); outlineObj.transform.SetParent(Rect, false); var outlineRect = outlineObj.AddComponent(); outlineRect.anchorMin = Vector2.zero; outlineRect.anchorMax = Vector2.one; outlineRect.offsetMin = Vector2.zero; outlineRect.offsetMax = Vector2.zero; var outlineImg = outlineObj.AddComponent(); outlineImg.sprite = Ctx.Sprites.RoundedOutline; outlineImg.type = Image.Type.Sliced; outlineImg.color = BorderColor; outlineImg.raycastTarget = false; } private static bool IsLeftPress(BaseEventData e) => e is PointerEventData ped && ped.button == PointerEventData.InputButton.Left; private void OnSegmentHoverEnter(SegmentItem item) { if (IsDisposed || _blocked) return; item.Hovered = true; if (item.Pressed) return; var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover; item.Tint(Ctx, targetColor, 0.12f); } private void OnSegmentHoverExit(SegmentItem item) { if (IsDisposed) return; item.Hovered = false; if (item.Pressed) return; var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); var targetColor = isSelected ? ActiveColor : NormalColor; item.Tint(Ctx, targetColor, 0.12f); } private void OnSegmentPressEnter(SegmentItem item) { if (IsDisposed || _blocked) return; item.Pressed = true; var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover; item.Tint(Ctx, targetColor, 0.08f); } private void OnSegmentPressExit(SegmentItem item) { if (IsDisposed || !item.Pressed) return; item.Pressed = false; var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); Color settle; if (isSelected) { settle = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor; } else { settle = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor; } item.Tint(Ctx, settle, 0.12f); } public void SelectOption(T newValue, bool notify = true) { if (IsDisposed) return; if (EqualityComparer.Default.Equals(_value, newValue)) return; _value = newValue; UpdateVisual(); if (notify) { OnChanged?.Invoke(_value); } } public void Set(T value, bool notify = true) { if (IsDisposed) return; if (EqualityComparer.Default.Equals(_value, value)) return; _value = value; UpdateVisual(); if (notify) { OnChanged?.Invoke(_value); } } public void Reset(bool notify = true) { Set(DefaultValue, notify); } public override void SetBlocked(bool blocked, bool noAnimate = false) { _blocked = blocked; base.SetBlocked(blocked, noAnimate); } public void UpdateVisual(bool noAnimate = false) { if (IsDisposed) return; foreach (var item in _items) { item.Pressed = false; var isSelected = EqualityComparer.Default.Equals(item.Option.Value, _value); Color targetBg; if (isSelected) { targetBg = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor; item.Label.color = Ctx.Theme.Text; item.Label.fontStyle = FontStyles.Bold; } else { targetBg = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor; item.Label.color = Ctx.Theme.TextDim; item.Label.fontStyle = FontStyles.Normal; } if (noAnimate) { item.HoverTween?.Kill(); item.Background.color = targetBg; } else { item.Tint(Ctx, targetBg, 0.15f); } } } public override void Dispose() { if (IsDisposed) return; foreach (var item in _items) { item.HoverTween?.Kill(); item.HoverTween = null; } OnChanged = null; base.Dispose(); } }