Initial commit

This commit is contained in:
2022-12-30 00:58:33 +01:00
commit 3f1075e67f
176 changed files with 25715 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace PeterHan.PLib.UI {
/// <summary>
/// A UI component which can be dynamically resized for its content.
/// </summary>
public interface IDynamicSizable : IUIComponent {
/// <summary>
/// Whether the component should dynamically resize for its content. This adds more
/// components and more layout depth, so should only be enabled if necessary.
///
/// Defaults to false. Must be set to true for components with a nonzero flex size.
/// </summary>
bool DynamicSize { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// Describes a UI component whose flexible size can be mutated.
/// </summary>
internal interface ISettableFlexSize : ILayoutGroup {
/// <summary>
/// The flexible width of the completed layout group can be set.
/// </summary>
float flexibleWidth { get; set; }
/// <summary>
/// The flexible height of the completed layout group can be set.
/// </summary>
float flexibleHeight { get; set; }
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace PeterHan.PLib.UI {
public interface ITooltipListableOption : IListableOption {
/// <summary>
/// Retrieves the tool tip text for this option.
/// </summary>
/// <returns>The text to be shown on the tool tip.</returns>
string GetToolTipText();
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// Implemented by PLib UI components.
/// </summary>
public interface IUIComponent {
/// <summary>
/// The component name.
/// </summary>
string Name { get; }
/// <summary>
/// Creates a physical game object embodying this component.
/// </summary>
/// <returns>The game object representing this UI component. Multiple invocations return
/// unique objects.</returns>
GameObject Build();
/// <summary>
/// Actions invoked when the UI component is actually realized.
/// </summary>
event PUIDelegates.OnRealize OnRealize;
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
namespace PeterHan.PLib.UI {
/// <summary>
/// An enumeration describing how to transform the image in a label.
///
/// Rotations are counterclockwise from 0 (straight up).
/// </summary>
[Flags]
public enum ImageTransform : uint {
None = 0,
FlipHorizontal = 1,
FlipVertical = 2,
Rotate90 = 4,
Rotate180 = 8,
Rotate270 = Rotate90 | Rotate180
}
}

88
mod/PLibUI/LayoutSizes.cs Normal file
View File

@@ -0,0 +1,88 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// A class representing the size sets of a particular component.
/// </summary>
internal struct LayoutSizes {
/// <summary>
/// The flexible dimension value.
/// </summary>
public float flexible;
/// <summary>
/// If true, this component should be ignored completely.
/// </summary>
public bool ignore;
/// <summary>
/// The minimum dimension value.
/// </summary>
public float min;
/// <summary>
/// The preferred dimension value.
/// </summary>
public float preferred;
/// <summary>
/// The source of these values.
/// </summary>
public readonly GameObject source;
internal LayoutSizes(GameObject source) : this(source, 0.0f, 0.0f, 0.0f) { }
internal LayoutSizes(GameObject source, float min, float preferred,
float flexible) {
ignore = false;
this.source = source;
this.flexible = flexible;
this.min = min;
this.preferred = preferred;
}
/// <summary>
/// Adds another set of layout sizes to this one.
/// </summary>
/// <param name="other">The size values to add.</param>
public void Add(LayoutSizes other) {
flexible += other.flexible;
min += other.min;
preferred += other.preferred;
}
/// <summary>
/// Enlarges this layout size, if necessary, using the values from another.
/// </summary>
/// <param name="other">The minimum size values to enforce.</param>
public void Max(LayoutSizes other) {
flexible = Math.Max(flexible, other.flexible);
min = Math.Max(min, other.min);
preferred = Math.Max(preferred, other.preferred);
}
public override string ToString() {
return string.Format("LayoutSizes[min={0:F2},preferred={1:F2},flexible={2:F2}]",
min, preferred, flexible);
}
}
}

View File

@@ -0,0 +1,239 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// The abstract parent of most layout groups.
/// </summary>
public abstract class AbstractLayoutGroup : UIBehaviour, ISettableFlexSize, ILayoutElement
{
/// <summary>
/// Sets an object's layout dirty on the next frame.
/// </summary>
/// <param name="transform">The transform to set dirty.</param>
/// <returns>A coroutine to set it dirty.</returns>
internal static IEnumerator DelayedSetDirty(RectTransform transform) {
yield return null;
LayoutRebuilder.MarkLayoutForRebuild(transform);
}
/// <summary>
/// Removes and destroys any PLib layouts on the component. They will be replaced with
/// a static LayoutElement containing the old size of the component.
/// </summary>
/// <param name="component">The component to cleanse.</param>
internal static void DestroyAndReplaceLayout(GameObject component) {
if (component != null && component.TryGetComponent(out AbstractLayoutGroup
layoutGroup)) {
var replacement = component.AddOrGet<LayoutElement>();
replacement.flexibleHeight = layoutGroup.flexibleHeight;
replacement.flexibleWidth = layoutGroup.flexibleWidth;
replacement.layoutPriority = layoutGroup.layoutPriority;
replacement.minHeight = layoutGroup.minHeight;
replacement.minWidth = layoutGroup.minWidth;
replacement.preferredHeight = layoutGroup.preferredHeight;
replacement.preferredWidth = layoutGroup.preferredWidth;
DestroyImmediate(layoutGroup);
}
}
public float minWidth {
get {
return mMinWidth;
}
set {
mMinWidth = value;
}
}
public float preferredWidth {
get {
return mPreferredWidth;
}
set {
mPreferredWidth = value;
}
}
/// <summary>
/// The flexible width of the completed layout group can be set.
/// </summary>
public float flexibleWidth {
get {
return mFlexibleWidth;
}
set {
mFlexibleWidth = value;
}
}
public float minHeight {
get {
return mMinHeight;
}
set {
mMinHeight = value;
}
}
public float preferredHeight {
get {
return mPreferredHeight;
}
set {
mPreferredHeight = value;
}
}
/// <summary>
/// The flexible height of the completed layout group can be set.
/// </summary>
public float flexibleHeight {
get {
return mFlexibleHeight;
}
set {
mFlexibleHeight = value;
}
}
/// <summary>
/// The priority of this layout group.
/// </summary>
public int layoutPriority {
get {
return mLayoutPriority;
}
set {
mLayoutPriority = value;
}
}
protected RectTransform rectTransform {
get {
if (cachedTransform == null)
cachedTransform = gameObject.rectTransform();
return cachedTransform;
}
}
/// <summary>
/// Whether the layout is currently locked.
/// </summary>
[SerializeField]
protected bool locked;
// The backing fields must be annotated with the attribute to have prefabbed versions
// successfully copy the values
[SerializeField]
private float mMinWidth, mMinHeight, mPreferredWidth, mPreferredHeight;
[SerializeField]
private float mFlexibleWidth, mFlexibleHeight;
[SerializeField]
private int mLayoutPriority;
/// <summary>
/// The cached rect transform to speed up layout.
/// </summary>
private RectTransform cachedTransform;
protected AbstractLayoutGroup() {
cachedTransform = null;
locked = false;
mLayoutPriority = 1;
}
public abstract void CalculateLayoutInputHorizontal();
public abstract void CalculateLayoutInputVertical();
/// <summary>
/// Triggers a layout with the current parent, and then locks the layout size. Further
/// attempts to automatically lay out the component, unless UnlockLayout is called,
/// will not trigger any action.
///
/// The resulting layout has very good performance, but cannot adapt to changes in the
/// size of its children or its own size.
/// </summary>
/// <returns>The computed size of this component when locked.</returns>
public virtual Vector2 LockLayout() {
var rt = gameObject.rectTransform();
if (rt != null) {
locked = false;
CalculateLayoutInputHorizontal();
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, minWidth);
SetLayoutHorizontal();
CalculateLayoutInputVertical();
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, minHeight);
SetLayoutVertical();
locked = true;
}
return new Vector2(minWidth, minHeight);
}
protected override void OnDidApplyAnimationProperties() {
base.OnDidApplyAnimationProperties();
SetDirty();
}
protected override void OnDisable() {
base.OnDisable();
SetDirty();
}
protected override void OnEnable() {
base.OnEnable();
SetDirty();
}
protected override void OnRectTransformDimensionsChange() {
base.OnRectTransformDimensionsChange();
SetDirty();
}
/// <summary>
/// Sets this layout as dirty.
/// </summary>
protected virtual void SetDirty() {
if (gameObject != null && IsActive()) {
if (CanvasUpdateRegistry.IsRebuildingLayout())
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
else
StartCoroutine(DelayedSetDirty(rectTransform));
}
}
public abstract void SetLayoutHorizontal();
public abstract void SetLayoutVertical();
/// <summary>
/// Unlocks the layout, allowing it to again dynamically resize when component sizes
/// are changed.
/// </summary>
public virtual void UnlockLayout() {
locked = false;
LayoutRebuilder.MarkLayoutForRebuild(gameObject.rectTransform());
}
}
}

View File

@@ -0,0 +1,266 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
//#define DEBUG_LAYOUT
using PeterHan.PLib.UI.Layouts;
using System;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A freezable, flexible layout manager that fixes the issues I am having with
/// HorizontalLayoutGroup and VerticalLayoutGroup. You get a content size fitter for
/// free too!
///
/// Intended to work something like Java's BoxLayout...
/// </summary>
public sealed class BoxLayoutGroup : AbstractLayoutGroup {
/// <summary>
/// Calculates the size of the box layout container.
/// </summary>
/// <param name="obj">The container to lay out.</param>
/// <param name="args">The parameters to use for layout.</param>
/// <param name="direction">The direction which is being calculated.</param>
/// <returns>The minimum and preferred box layout size.</returns>
private static BoxLayoutResults Calc(GameObject obj, BoxLayoutParams args,
PanelDirection direction) {
var transform = obj.AddOrGet<RectTransform>();
int n = transform.childCount;
var result = new BoxLayoutResults(direction, n);
var components = ListPool<Component, BoxLayoutGroup>.Allocate();
for (int i = 0; i < n; i++) {
var child = transform.GetChild(i)?.gameObject;
if (child != null && child.activeInHierarchy) {
// Only on active game objects
components.Clear();
child.GetComponents(components);
var hc = PUIUtils.CalcSizes(child, direction, components);
if (!hc.ignore) {
if (args.Direction == direction)
result.Accum(hc, args.Spacing);
else
result.Expand(hc);
result.children.Add(hc);
}
}
}
components.Recycle();
return result;
}
/// <summary>
/// Lays out components in the box layout container.
/// </summary>
/// <param name="args">The parameters to use for layout.</param>
/// <param name="required">The calculated minimum and preferred sizes.</param>
/// <param name="size">The total available size in this dimension.</param>
private static void DoLayout(BoxLayoutParams args, BoxLayoutResults required,
float size) {
if (required == null)
throw new ArgumentNullException(nameof(required));
var direction = required.direction;
var status = new BoxLayoutStatus(direction, args.Margin ?? new RectOffset(), size);
if (args.Direction == direction)
DoLayoutLinear(required, args, status);
else
DoLayoutPerp(required, args, status);
}
/// <summary>
/// Lays out components in the box layout container parallel to the layout axis.
/// </summary>
/// <param name="required">The calculated minimum and preferred sizes.</param>
/// <param name="args">The parameters to use for layout.</param>
/// <param name="status">The current status of layout.</param>
private static void DoLayoutLinear(BoxLayoutResults required, BoxLayoutParams args,
BoxLayoutStatus status) {
var total = required.total;
var components = ListPool<ILayoutController, BoxLayoutGroup>.Allocate();
var direction = args.Direction;
// Determine flex size ratio
float size = status.size, prefRatio = 0.0f, minSize = total.min, prefSize =
total.preferred, excess = Math.Max(0.0f, size - prefSize), flexTotal = total.
flexible, offset = status.offset, spacing = args.Spacing;
if (size > minSize && prefSize > minSize)
// Do not divide by 0
prefRatio = Math.Min(1.0f, (size - minSize) / (prefSize - minSize));
if (excess > 0.0f && flexTotal == 0.0f)
// If no components can be expanded, offset all
offset += PUIUtils.GetOffset(args.Alignment, status.direction, excess);
foreach (var child in required.children) {
var obj = child.source;
// Active objects only
if (obj != null && obj.activeInHierarchy) {
float compSize = child.min;
if (prefRatio > 0.0f)
compSize += (child.preferred - child.min) * prefRatio;
if (excess > 0.0f && flexTotal > 0.0f)
compSize += excess * child.flexible / flexTotal;
// Place and size component
obj.AddOrGet<RectTransform>().SetInsetAndSizeFromParentEdge(status.edge,
offset, compSize);
offset += compSize + ((compSize > 0.0f) ? spacing : 0.0f);
// Invoke SetLayout on dependents
components.Clear();
obj.GetComponents(components);
foreach (var component in components)
if (direction == PanelDirection.Horizontal)
component.SetLayoutHorizontal();
else // if (direction == PanelDirection.Vertical)
component.SetLayoutVertical();
}
}
components.Recycle();
}
/// <summary>
/// Lays out components in the box layout container against the layout axis.
/// </summary>
/// <param name="required">The calculated minimum and preferred sizes.</param>
/// <param name="args">The parameters to use for layout.</param>
/// <param name="status">The current status of layout.</param>
private static void DoLayoutPerp(BoxLayoutResults required, BoxLayoutParams args,
BoxLayoutStatus status) {
var components = ListPool<ILayoutController, BoxLayoutGroup>.Allocate();
var direction = args.Direction;
float size = status.size;
foreach (var child in required.children) {
var obj = child.source;
// Active objects only
if (obj != null && obj.activeInHierarchy) {
float compSize = size;
if (child.flexible <= 0.0f)
// Does not expand to all
compSize = Math.Min(compSize, child.preferred);
float offset = (size > compSize) ? PUIUtils.GetOffset(args.Alignment,
status.direction, size - compSize) : 0.0f;
// Place and size component
obj.AddOrGet<RectTransform>().SetInsetAndSizeFromParentEdge(status.edge,
offset + status.offset, compSize);
// Invoke SetLayout on dependents
components.Clear();
obj.GetComponents(components);
foreach (var component in components)
if (direction == PanelDirection.Horizontal)
component.SetLayoutVertical();
else // if (direction == PanelDirection.Vertical)
component.SetLayoutHorizontal();
}
}
components.Recycle();
}
/// <summary>
/// The parameters used to set up this box layout.
/// </summary>
public BoxLayoutParams Params {
get {
return parameters;
}
set {
parameters = value ?? throw new ArgumentNullException(nameof(Params));
}
}
/// <summary>
/// Results from the horizontal calculation pass.
/// </summary>
private BoxLayoutResults horizontal;
/// <summary>
/// The parameters used to set up this box layout.
/// </summary>
[SerializeField]
private BoxLayoutParams parameters;
/// <summary>
/// Results from the vertical calculation pass.
/// </summary>
private BoxLayoutResults vertical;
internal BoxLayoutGroup() {
horizontal = null;
layoutPriority = 1;
parameters = new BoxLayoutParams();
vertical = null;
}
public override void CalculateLayoutInputHorizontal() {
if (!locked) {
var margin = parameters.Margin;
float gap = (margin == null) ? 0.0f : margin.left + margin.right;
horizontal = Calc(gameObject, parameters, PanelDirection.Horizontal);
var hTotal = horizontal.total;
minWidth = hTotal.min + gap;
preferredWidth = hTotal.preferred + gap;
#if DEBUG_LAYOUT
PUIUtils.LogUIDebug("CalculateLayoutInputHorizontal for {0} preferred {1:F2}".
F(gameObject.name, preferredWidth));
#endif
}
}
public override void CalculateLayoutInputVertical() {
if (!locked) {
var margin = parameters.Margin;
float gap = (margin == null) ? 0.0f : margin.top + margin.bottom;
vertical = Calc(gameObject, parameters, PanelDirection.Vertical);
var vTotal = vertical.total;
minHeight = vTotal.min + gap;
preferredHeight = vTotal.preferred + gap;
#if DEBUG_LAYOUT
PUIUtils.LogUIDebug("CalculateLayoutInputVertical for {0} preferred {1:F2}".F(
gameObject.name, preferredHeight));
#endif
}
}
protected override void OnDisable() {
base.OnDisable();
horizontal = null;
vertical = null;
}
protected override void OnEnable() {
base.OnEnable();
horizontal = null;
vertical = null;
}
public override void SetLayoutHorizontal() {
if (horizontal != null && !locked) {
#if DEBUG_LAYOUT
PUIUtils.LogUIDebug("SetLayoutHorizontal for {0} resolved width to {1:F2}".F(
gameObject.name, rectTransform.rect.width));
#endif
DoLayout(parameters, horizontal, rectTransform.rect.width);
}
}
public override void SetLayoutVertical() {
if (vertical != null && !locked) {
#if DEBUG_LAYOUT
PUIUtils.LogUIDebug("SetLayoutVertical for {0} resolved height to {1:F2}".F(
gameObject.name, rectTransform.rect.height));
#endif
DoLayout(parameters, vertical, rectTransform.rect.height);
}
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// The parameters used for laying out a box layout.
/// </summary>
[Serializable]
public sealed class BoxLayoutParams {
/// <summary>
/// The alignment to use for components that are not big enough to fit and have no
/// flexible width.
/// </summary>
public TextAnchor Alignment { get; set; }
/// <summary>
/// The direction of layout.
/// </summary>
public PanelDirection Direction { get; set; }
/// <summary>
/// The margin between the children and the component edge.
/// </summary>
public RectOffset Margin { get; set; }
/// <summary>
/// The spacing between components.
/// </summary>
public float Spacing { get; set; }
public BoxLayoutParams() {
Alignment = TextAnchor.MiddleCenter;
Direction = PanelDirection.Horizontal;
Margin = null;
Spacing = 0.0f;
}
public override string ToString() {
return string.Format("BoxLayoutParams[Alignment={0},Direction={1},Spacing={2:F2}]",
Alignment, Direction, Spacing);
}
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using UnityEngine;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// A class which stores the results of a single box layout calculation pass.
/// </summary>
internal sealed class BoxLayoutResults {
/// <summary>
/// The components which were laid out.
/// </summary>
public readonly ICollection<LayoutSizes> children;
/// <summary>
/// The current direction of flow.
/// </summary>
public readonly PanelDirection direction;
/// <summary>
/// Whether any spaces have been added yet for minimum size.
/// </summary>
private bool haveMinSpace;
/// <summary>
/// Whether any spaces have been added yet for preferred size.
/// </summary>
private bool havePrefSpace;
/// <summary>
/// The total sizes.
/// </summary>
public LayoutSizes total;
internal BoxLayoutResults(PanelDirection direction, int presize) {
children = new List<LayoutSizes>(presize);
this.direction = direction;
haveMinSpace = false;
havePrefSpace = false;
total = new LayoutSizes();
}
/// <summary>
/// Accumulates another component into the results.
/// </summary>
/// <param name="sizes">The size of the component to add.</param>
/// <param name="spacing">The component spacing.</param>
public void Accum(LayoutSizes sizes, float spacing) {
float newMin = sizes.min, newPreferred = sizes.preferred;
if (newMin > 0.0f) {
// Skip one space
if (haveMinSpace)
newMin += spacing;
haveMinSpace = true;
}
total.min += newMin;
if (newPreferred > 0.0f) {
// Skip one space
if (havePrefSpace)
newPreferred += spacing;
havePrefSpace = true;
}
total.preferred += newPreferred;
total.flexible += sizes.flexible;
}
/// <summary>
/// Expands the results around another component.
/// </summary>
/// <param name="sizes">The size of the component to expand to.</param>
public void Expand(LayoutSizes sizes) {
float newMin = sizes.min, newPreferred = sizes.preferred, newFlexible = sizes.
flexible;
if (newMin > total.min)
total.min = newMin;
if (newPreferred > total.preferred)
total.preferred = newPreferred;
if (newFlexible > total.flexible)
total.flexible = newFlexible;
}
public override string ToString() {
return direction + " " + total;
}
}
/// <summary>
/// Maintains the status of a layout in progress.
/// </summary>
internal sealed class BoxLayoutStatus {
/// <summary>
/// The current direction of flow.
/// </summary>
public readonly PanelDirection direction;
/// <summary>
/// The edge from where layout started.
/// </summary>
public readonly RectTransform.Edge edge;
/// <summary>
/// The next component's offset.
/// </summary>
public readonly float offset;
/// <summary>
/// The component size in that direction minus margins.
/// </summary>
public readonly float size;
internal BoxLayoutStatus(PanelDirection direction, RectOffset margins, float size) {
this.direction = direction;
switch (direction) {
case PanelDirection.Horizontal:
edge = RectTransform.Edge.Left;
offset = margins.left;
this.size = size - offset - margins.right;
break;
case PanelDirection.Vertical:
edge = RectTransform.Edge.Top;
offset = margins.top;
this.size = size - offset - margins.bottom;
break;
default:
throw new ArgumentException("direction");
}
}
}
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.UI.Layouts;
using System;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A freezable layout manager that displays one of its contained objects at a time.
/// Unlike other layout groups, even inactive children are considered for sizing.
/// </summary>
public sealed class CardLayoutGroup : AbstractLayoutGroup {
/// <summary>
/// Calculates the size of the card layout container.
/// </summary>
/// <param name="obj">The container to lay out.</param>
/// <param name="args">The parameters to use for layout.</param>
/// <param name="direction">The direction which is being calculated.</param>
/// <returns>The minimum and preferred box layout size.</returns>
private static CardLayoutResults Calc(GameObject obj, PanelDirection direction) {
var transform = obj.AddOrGet<RectTransform>();
int n = transform.childCount;
var result = new CardLayoutResults(direction, n);
var components = ListPool<Component, BoxLayoutGroup>.Allocate();
for (int i = 0; i < n; i++) {
var child = transform.GetChild(i)?.gameObject;
if (child != null) {
bool active = child.activeInHierarchy;
// Not only on active game objects
components.Clear();
child.GetComponents(components);
child.SetActive(true);
var hc = PUIUtils.CalcSizes(child, direction, components);
if (!hc.ignore) {
result.Expand(hc);
result.children.Add(hc);
}
child.SetActive(active);
}
}
components.Recycle();
return result;
}
/// <summary>
/// Lays out components in the card layout container.
/// </summary>
/// <param name="margin">The margin to allow around the components.</param>
/// <param name="required">The calculated minimum and preferred sizes.</param>
/// <param name="size">The total available size in this dimension.</param>
private static void DoLayout(RectOffset margin, CardLayoutResults required, float size)
{
if (required == null)
throw new ArgumentNullException(nameof(required));
var direction = required.direction;
var components = ListPool<ILayoutController, BoxLayoutGroup>.Allocate();
// Compensate for margins
if (direction == PanelDirection.Horizontal)
size -= margin.left + margin.right;
else
size -= margin.top + margin.bottom;
foreach (var child in required.children) {
var obj = child.source;
if (obj != null) {
float compSize = PUIUtils.GetProperSize(child, size);
// Place and size component
var transform = obj.AddOrGet<RectTransform>();
if (direction == PanelDirection.Horizontal)
transform.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Left,
margin.left, compSize);
else
transform.SetInsetAndSizeFromParentEdge(RectTransform.Edge.Top,
margin.top, compSize);
// Invoke SetLayout on dependents
components.Clear();
obj.GetComponents(components);
foreach (var component in components)
if (direction == PanelDirection.Horizontal)
component.SetLayoutHorizontal();
else // if (direction == PanelDirection.Vertical)
component.SetLayoutVertical();
}
}
components.Recycle();
}
/// <summary>
/// The margin around the components as a whole.
/// </summary>
public RectOffset Margin {
get {
return margin;
}
set {
margin = value;
}
}
/// <summary>
/// Results from the horizontal calculation pass.
/// </summary>
private CardLayoutResults horizontal;
/// <summary>
/// The margin around the components as a whole.
/// </summary>
[SerializeField]
private RectOffset margin;
/// <summary>
/// Results from the vertical calculation pass.
/// </summary>
private CardLayoutResults vertical;
internal CardLayoutGroup() {
horizontal = null;
layoutPriority = 1;
vertical = null;
}
public override void CalculateLayoutInputHorizontal() {
if (!locked) {
var margin = Margin;
float gap = (margin == null) ? 0.0f : margin.left + margin.right;
horizontal = Calc(gameObject, PanelDirection.Horizontal);
var hTotal = horizontal.total;
minWidth = hTotal.min + gap;
preferredWidth = hTotal.preferred + gap;
}
}
public override void CalculateLayoutInputVertical() {
if (!locked) {
var margin = Margin;
float gap = (margin == null) ? 0.0f : margin.top + margin.bottom;
vertical = Calc(gameObject, PanelDirection.Vertical);
var vTotal = vertical.total;
minHeight = vTotal.min + gap;
preferredHeight = vTotal.preferred + gap;
}
}
protected override void OnDisable() {
base.OnDisable();
horizontal = null;
vertical = null;
}
protected override void OnEnable() {
base.OnEnable();
horizontal = null;
vertical = null;
}
/// <summary>
/// Switches the active card.
/// </summary>
/// <param name="card">The child to make active, or null to inactivate all children.</param>
public void SetActiveCard(GameObject card) {
int n = transform.childCount;
for (int i = 0; i < n; i++) {
var child = transform.GetChild(i)?.gameObject;
if (child != null)
child.SetActive(child == card);
}
}
/// <summary>
/// Switches the active card.
/// </summary>
/// <param name="index">The child index to make active, or -1 to inactivate all children.</param>
public void SetActiveCard(int index) {
int n = transform.childCount;
for (int i = 0; i < n; i++) {
var child = transform.GetChild(i)?.gameObject;
if (child != null)
child.SetActive(i == index);
}
}
public override void SetLayoutHorizontal() {
if (horizontal != null && !locked)
DoLayout(Margin ?? new RectOffset(), horizontal, rectTransform.rect.width);
}
public override void SetLayoutVertical() {
if (vertical != null && !locked)
DoLayout(Margin ?? new RectOffset(), vertical, rectTransform.rect.height);
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System.Collections.Generic;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// A class which stores the results of a single card layout calculation pass.
/// </summary>
internal sealed class CardLayoutResults {
/// <summary>
/// The components which were laid out.
/// </summary>
public readonly ICollection<LayoutSizes> children;
/// <summary>
/// The current direction of flow.
/// </summary>
public readonly PanelDirection direction;
/// <summary>
/// The total sizes.
/// </summary>
public LayoutSizes total;
internal CardLayoutResults(PanelDirection direction, int presize) {
children = new List<LayoutSizes>(presize);
this.direction = direction;
total = new LayoutSizes();
}
/// <summary>
/// Expands the results around another component.
/// </summary>
/// <param name="sizes">The size of the component to expand to.</param>
public void Expand(LayoutSizes sizes) {
float newMin = sizes.min, newPreferred = sizes.preferred, newFlexible =
sizes.flexible;
if (newMin > total.min)
total.min = newMin;
if (newPreferred > total.preferred)
total.preferred = newPreferred;
if (newFlexible > total.flexible)
total.flexible = newFlexible;
}
public override string ToString() {
return direction + " " + total;
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
namespace PeterHan.PLib.UI {
/// <summary>
/// A component in the grid with its placement information.
/// </summary>
[Serializable]
internal sealed class GridComponent<T> : GridComponentSpec where T : class {
/// <summary>
/// The object to place here.
/// </summary>
public T Item { get; }
internal GridComponent(GridComponentSpec spec, T item) {
Alignment = spec.Alignment;
Item = item;
Column = spec.Column;
ColumnSpan = spec.ColumnSpan;
Margin = spec.Margin;
Row = spec.Row;
RowSpan = spec.RowSpan;
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using System;
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// Stores the state of a component in a grid layout.
/// </summary>
public class GridComponentSpec {
/// <summary>
/// The alignment of the component.
/// </summary>
public TextAnchor Alignment { get; set; }
/// <summary>
/// The column of the component.
/// </summary>
public int Column { get; set; }
/// <summary>
/// The number of columns this component spans.
/// </summary>
public int ColumnSpan { get; set; }
/// <summary>
/// The margin to allocate around each component.
/// </summary>
public RectOffset Margin { get; set; }
/// <summary>
/// The row of the component.
/// </summary>
public int Row { get; set; }
/// <summary>
/// The number of rows this component spans.
/// </summary>
public int RowSpan { get; set; }
internal GridComponentSpec() { }
/// <summary>
/// Creates a new grid component specification. While the row and column are mandatory,
/// the other attributes can be optionally specified in the initializer.
/// </summary>
/// <param name="row">The row to place the component.</param>
/// <param name="column">The column to place the component.</param>
public GridComponentSpec(int row, int column) {
if (row < 0)
throw new ArgumentOutOfRangeException(nameof(row));
if (column < 0)
throw new ArgumentOutOfRangeException(nameof(column));
Alignment = TextAnchor.MiddleCenter;
Row = row;
Column = column;
Margin = null;
RowSpan = 1;
ColumnSpan = 1;
}
public override string ToString() {
return string.Format("GridComponentSpec[Row={0:D},Column={1:D},RowSpan={2:D},ColumnSpan={3:D}]",
Row, Column, RowSpan, ColumnSpan);
}
}
/// <summary>
/// The specifications for one column in a grid layout.
/// </summary>
[Serializable]
public sealed class GridColumnSpec {
/// <summary>
/// The flexible width of this grid column. If there is space left after all
/// columns get their nominal width, each column will get a fraction of the space
/// left proportional to their FlexWidth value as a ratio to the total flexible
/// width values.
/// </summary>
public float FlexWidth { get; }
/// <summary>
/// The nominal width of this grid column. If zero, the preferred width of the
/// largest component is used. If there are no components in this column (possibly
/// because the only components in this row all have column spans from other
/// columns), the width will be zero!
/// </summary>
public float Width { get; }
/// <summary>
/// Creates a new grid column specification.
/// </summary>
/// <param name="width">The column's base width, or 0 to auto-size the column to the
/// preferred width of its largest component.</param>
/// <param name="flex">The percentage of the leftover width the column should occupy.</param>
public GridColumnSpec(float width = 0.0f, float flex = 0.0f) {
if (width.IsNaNOrInfinity() || width < 0.0f)
throw new ArgumentOutOfRangeException(nameof(width));
if (flex.IsNaNOrInfinity() || flex < 0.0f)
throw new ArgumentOutOfRangeException(nameof(flex));
Width = width;
FlexWidth = flex;
}
public override string ToString() {
return string.Format("GridColumnSpec[Width={0:F2}]", Width);
}
}
/// <summary>
/// The specifications for one row in a grid layout.
/// </summary>
[Serializable]
public sealed class GridRowSpec {
/// <summary>
/// The flexible height of this grid row. If there is space left after all rows
/// get their nominal height, each row will get a fraction of the space left
/// proportional to their FlexHeight value as a ratio to the total flexible
/// height values.
/// </summary>
public float FlexHeight { get; }
/// <summary>
/// The nominal height of this grid row. If zero, the preferred height of the
/// largest component is used. If there are no components in this row (possibly
/// because the only components in this row all have row spans from other rows),
/// the height will be zero!
/// </summary>
public float Height { get; }
/// <summary>
/// Creates a new grid row specification.
/// </summary>
/// <param name="height">The row's base width, or 0 to auto-size the row to the
/// preferred height of its largest component.</param>
/// <param name="flex">The percentage of the leftover height the row should occupy.</param>
public GridRowSpec(float height = 0.0f, float flex = 0.0f) {
if (height.IsNaNOrInfinity() || height < 0.0f)
throw new ArgumentOutOfRangeException(nameof(height));
if (flex.IsNaNOrInfinity() || flex < 0.0f)
throw new ArgumentOutOfRangeException(nameof(flex));
Height = height;
FlexHeight = flex;
}
public override string ToString() {
return string.Format("GridRowSpec[Height={0:F2}]", Height);
}
}
}

View File

@@ -0,0 +1,304 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using UnityEngine;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// A class which stores the results of a single grid layout calculation pass.
/// </summary>
internal sealed class GridLayoutResults {
/// <summary>
/// Builds a matrix of the components at each given location. Components only are
/// entered at their origin cell (ignoring row and column span).
/// </summary>
/// <param name="rows">The maximum number of rows.</param>
/// <param name="columns">The maximum number of columns.</param>
/// <param name="components">The components to add.</param>
/// <returns>A 2-D array of the components at a given row/column location.</returns>
private static ICollection<SizedGridComponent>[,] GetMatrix(int rows, int columns,
ICollection<SizedGridComponent> components) {
var spec = new ICollection<SizedGridComponent>[rows, columns];
foreach (var component in components) {
int x = component.Row, y = component.Column;
if (x >= 0 && x < rows && y >= 0 && y < columns) {
// Multiple components are allowed at a given cell
var atLocation = spec[x, y];
if (atLocation == null)
spec[x, y] = atLocation = new List<SizedGridComponent>(8);
atLocation.Add(component);
}
}
return spec;
}
/// <summary>
/// The columns in the grid.
/// </summary>
public IList<GridColumnSpec> ColumnSpecs { get; }
/// <summary>
/// The components in the grid, in order of addition.
/// </summary>
public ICollection<SizedGridComponent> Components { get; }
/// <summary>
/// The number of columns in the grid.
/// </summary>
public int Columns { get; }
/// <summary>
/// The columns in the grid with their calculated widths.
/// </summary>
public IList<GridColumnSpec> ComputedColumnSpecs { get; }
/// <summary>
/// The rows in the grid with their calculated heights.
/// </summary>
public IList<GridRowSpec> ComputedRowSpecs { get; }
/// <summary>
/// The minimum total height.
/// </summary>
public float MinHeight { get; private set; }
/// <summary>
/// The minimum total width.
/// </summary>
public float MinWidth { get; private set; }
/// <summary>
/// The components which were laid out.
/// </summary>
public ICollection<SizedGridComponent>[,] Matrix { get; }
/// <summary>
/// The rows in the grid.
/// </summary>
public IList<GridRowSpec> RowSpecs { get; }
/// <summary>
/// The number of rows in the grid.
/// </summary>
public int Rows { get; }
/// <summary>
/// The total flexible height weights.
/// </summary>
public float TotalFlexHeight { get; private set; }
/// <summary>
/// The total flexible width weights.
/// </summary>
public float TotalFlexWidth { get; private set; }
internal GridLayoutResults(IList<GridRowSpec> rows, IList<GridColumnSpec> columns,
ICollection<GridComponent<GameObject>> components) {
if (rows == null)
throw new ArgumentNullException(nameof(rows));
if (columns == null)
throw new ArgumentNullException(nameof(columns));
if (components == null)
throw new ArgumentNullException(nameof(components));
Columns = columns.Count;
Rows = rows.Count;
ColumnSpecs = columns;
MinHeight = MinWidth = 0.0f;
RowSpecs = rows;
ComputedColumnSpecs = new List<GridColumnSpec>(Columns);
ComputedRowSpecs = new List<GridRowSpec>(Rows);
TotalFlexHeight = TotalFlexWidth = 0.0f;
// Populate alive components
Components = new List<SizedGridComponent>(Math.Max(components.Count, 4));
foreach (var component in components) {
var item = component.Item;
if (item != null)
Components.Add(new SizedGridComponent(component, item));
}
Matrix = GetMatrix(Rows, Columns, Components);
}
/// <summary>
/// Calculates the base height of each row, the minimum it gets before extra space
/// is distributed.
/// </summary>
internal void CalcBaseHeights() {
int rows = Rows;
MinHeight = TotalFlexHeight = 0.0f;
ComputedRowSpecs.Clear();
for (int row = 0; row < rows; row++) {
var spec = RowSpecs[row];
float height = spec.Height, flex = spec.FlexHeight;
if (height <= 0.0f)
// Auto height
for (int i = 0; i < Columns; i++)
height = Math.Max(height, PreferredHeightAt(row, i));
if (flex > 0.0f)
TotalFlexHeight += flex;
ComputedRowSpecs.Add(new GridRowSpec(height, flex));
}
foreach (var component in Components)
if (component.RowSpan > 1)
ExpandMultiRow(component);
// Min height is calculated after all multirow components are distributed
for (int row = 0; row < rows; row++)
MinHeight += ComputedRowSpecs[row].Height;
}
/// <summary>
/// Calculates the base width of each row, the minimum it gets before extra space
/// is distributed.
/// </summary>
internal void CalcBaseWidths() {
int columns = Columns;
MinWidth = TotalFlexWidth = 0.0f;
ComputedColumnSpecs.Clear();
for (int column = 0; column < columns; column++) {
var spec = ColumnSpecs[column];
float width = spec.Width, flex = spec.FlexWidth;
if (width <= 0.0f)
// Auto width
for (int i = 0; i < Rows; i++)
width = Math.Max(width, PreferredWidthAt(i, column));
if (flex > 0.0f)
TotalFlexWidth += flex;
ComputedColumnSpecs.Add(new GridColumnSpec(width, flex));
}
foreach (var component in Components)
if (component.ColumnSpan > 1)
ExpandMultiColumn(component);
// Min width is calculated after all multicolumn components are distributed
for (int column = 0; column < columns; column++)
MinWidth += ComputedColumnSpecs[column].Width;
}
/// <summary>
/// For a multicolumn component, ratiometrically splits up any excess preferred size
/// among the columns in its span that have a flexible width.
/// </summary>
/// <param name="component">The component to reallocate sizes.</param>
private void ExpandMultiColumn(SizedGridComponent component) {
float need = component.HorizontalSize.preferred, totalFlex = 0.0f;
int start = component.Column, end = start + component.ColumnSpan;
for (int i = start; i < end; i++) {
var spec = ComputedColumnSpecs[i];
if (spec.FlexWidth > 0.0f)
totalFlex += spec.FlexWidth;
need -= spec.Width;
}
if (need > 0.0f && totalFlex > 0.0f)
// No flex = we can do nothing about it
for (int i = start; i < end; i++) {
var spec = ComputedColumnSpecs[i];
float flex = spec.FlexWidth;
if (flex > 0.0f)
ComputedColumnSpecs[i] = new GridColumnSpec(spec.Width + flex * need /
totalFlex, flex);
}
}
/// <summary>
/// For a multirow component, ratiometrically splits up any excess preferred size
/// among the rows in its span that have a flexible height.
/// </summary>
/// <param name="component">The component to reallocate sizes.</param>
private void ExpandMultiRow(SizedGridComponent component) {
float need = component.VerticalSize.preferred, totalFlex = 0.0f;
int start = component.Row, end = start + component.RowSpan;
for (int i = start; i < end; i++) {
var spec = ComputedRowSpecs[i];
if (spec.FlexHeight > 0.0f)
totalFlex += spec.FlexHeight;
need -= spec.Height;
}
if (need > 0.0f && totalFlex > 0.0f)
// No flex = we can do nothing about it
for (int i = start; i < end; i++) {
var spec = ComputedRowSpecs[i];
float flex = spec.FlexHeight;
if (flex > 0.0f)
ComputedRowSpecs[i] = new GridRowSpec(spec.Height + flex * need /
totalFlex, flex);
}
}
/// <summary>
/// Retrieves the preferred height of a cell.
/// </summary>
/// <param name="row">The cell's row.</param>
/// <param name="column">The cell's column.</param>
/// <returns>The preferred height.</returns>
private float PreferredHeightAt(int row, int column) {
float size = 0.0f;
var atLocation = Matrix[row, column];
if (atLocation != null && atLocation.Count > 0)
foreach (var component in atLocation) {
var sizes = component.VerticalSize;
if (component.RowSpan < 2)
size = Math.Max(size, sizes.preferred);
}
return size;
}
/// <summary>
/// Retrieves the preferred width of a cell.
/// </summary>
/// <param name="row">The cell's row.</param>
/// <param name="column">The cell's column.</param>
/// <returns>The preferred width.</returns>
private float PreferredWidthAt(int row, int column) {
float size = 0.0f;
var atLocation = Matrix[row, column];
if (atLocation != null && atLocation.Count > 0)
foreach (var component in atLocation) {
var sizes = component.HorizontalSize;
if (component.ColumnSpan < 2)
size = Math.Max(size, sizes.preferred);
}
return size;
}
}
/// <summary>
/// A component in the grid with its sizes computed.
/// </summary>
internal sealed class SizedGridComponent : GridComponentSpec {
/// <summary>
/// The object and its computed horizontal sizes.
/// </summary>
public LayoutSizes HorizontalSize { get; set; }
/// <summary>
/// The object and its computed vertical sizes.
/// </summary>
public LayoutSizes VerticalSize { get; set; }
internal SizedGridComponent(GridComponentSpec spec, GameObject item) {
Alignment = spec.Alignment;
Column = spec.Column;
ColumnSpan = spec.ColumnSpan;
Margin = spec.Margin;
Row = spec.Row;
RowSpan = spec.RowSpan;
HorizontalSize = new LayoutSizes(item);
VerticalSize = new LayoutSizes(item);
}
}
}

View File

@@ -0,0 +1,349 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using PeterHan.PLib.UI.Layouts;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// Implements a flexible version of the base GridLayout.
/// </summary>
public sealed class PGridLayoutGroup : AbstractLayoutGroup {
/// <summary>
/// Calculates all column widths.
/// </summary>
/// <param name="results">The results from layout.</param>
/// <param name="width">The current container width.</param>
/// <param name="margin">The margins within the borders.</param>
/// <returns>The column widths.</returns>
private static float[] GetColumnWidths(GridLayoutResults results, float width,
RectOffset margin) {
int columns = results.Columns;
// Find out how much flexible size can be given out
float position = margin?.left ?? 0, right = margin?.right ?? 0;
float actualWidth = width - position - right, totalFlex = results.TotalFlexWidth,
excess = (totalFlex > 0.0f) ? (actualWidth - results.MinWidth) / totalFlex :
0.0f;
float[] colX = new float[columns + 1];
// Determine start of columns
for (int i = 0; i < columns; i++) {
var spec = results.ComputedColumnSpecs[i];
colX[i] = position;
position += spec.Width + spec.FlexWidth * excess;
}
colX[columns] = position;
return colX;
}
/// <summary>
/// Calculates all row heights.
/// </summary>
/// <param name="results">The results from layout.</param>
/// <param name="height">The current container height.</param>
/// <param name="margin">The margins within the borders.</param>
/// <returns>The row heights.</returns>
private static float[] GetRowHeights(GridLayoutResults results, float height,
RectOffset margin) {
int rows = results.Rows;
// Find out how much flexible size can be given out
float position = margin?.bottom ?? 0, top = margin?.top ?? 0;
float actualWidth = height - position - top, totalFlex = results.TotalFlexHeight,
excess = (totalFlex > 0.0f) ? (actualWidth - results.MinHeight) / totalFlex :
0.0f;
float[] rowY = new float[rows + 1];
// Determine start of rows
for (int i = 0; i < rows; i++) {
var spec = results.ComputedRowSpecs[i];
rowY[i] = position;
position += spec.Height + spec.FlexHeight * excess;
}
rowY[rows] = position;
return rowY;
}
/// <summary>
/// Calculates the final height of this component and applies it to the component.
/// </summary>
/// <param name="component">The component to calculate.</param>
/// <param name="rowY">The row locations from GetRowHeights.</param>
/// <returns>true if the height was applied, or false if the component was not laid out
/// due to being disposed or set to ignore layout.</returns>
private static bool SetFinalHeight(SizedGridComponent component, float[] rowY) {
var margin = component.Margin;
var sizes = component.VerticalSize;
var target = sizes.source;
bool ok = !sizes.ignore && target != null;
if (ok) {
int rows = rowY.Length - 1;
// Clamp first and last row occupied by this object
int first = component.Row, last = first + component.RowSpan;
first = first.InRange(0, rows - 1);
last = last.InRange(1, rows);
// Align correctly in the cell box
float y = rowY[first], rowHeight = rowY[last] - y;
if (margin != null) {
float border = margin.top + margin.bottom;
y += margin.top;
rowHeight -= border;
sizes.min -= border;
sizes.preferred -= border;
}
float actualHeight = PUIUtils.GetProperSize(sizes, rowHeight);
// Take alignment into account
y += PUIUtils.GetOffset(component.Alignment, PanelDirection.Vertical,
rowHeight - actualHeight);
target.rectTransform().SetInsetAndSizeFromParentEdge(RectTransform.Edge.
Top, y, actualHeight);
}
return ok;
}
/// <summary>
/// Calculates the final width of this component and applies it to the component.
/// </summary>
/// <param name="component">The component to calculate.</param>
/// <param name="colX">The column locations from GetColumnWidths.</param>
/// <returns>true if the width was applied, or false if the component was not laid out
/// due to being disposed or set to ignore layout.</returns>
private static bool SetFinalWidth(SizedGridComponent component, float[] colX) {
var margin = component.Margin;
var sizes = component.HorizontalSize;
var target = sizes.source;
bool ok = !sizes.ignore && target != null;
if (ok) {
int columns = colX.Length - 1;
// Clamp first and last column occupied by this object
int first = component.Column, last = first + component.ColumnSpan;
first = first.InRange(0, columns - 1);
last = last.InRange(1, columns);
// Align correctly in the cell box
float x = colX[first], colWidth = colX[last] - x;
if (margin != null) {
float border = margin.left + margin.right;
x += margin.left;
colWidth -= border;
sizes.min -= border;
sizes.preferred -= border;
}
float actualWidth = PUIUtils.GetProperSize(sizes, colWidth);
// Take alignment into account
x += PUIUtils.GetOffset(component.Alignment, PanelDirection.Horizontal,
colWidth - actualWidth);
target.rectTransform().SetInsetAndSizeFromParentEdge(RectTransform.Edge.
Left, x, actualWidth);
}
return ok;
}
/// <summary>
/// The margin around the components as a whole.
/// </summary>
public RectOffset Margin {
get {
return margin;
}
set {
margin = value;
}
}
#pragma warning disable IDE0044 // Cannot be readonly for Unity serialization to work
/// <summary>
/// The children of this panel.
/// </summary>
[SerializeField]
private IList<GridComponent<GameObject>> children;
/// <summary>
/// The columns in this panel.
/// </summary>
[SerializeField]
private IList<GridColumnSpec> columns;
/// <summary>
/// The margin around the components as a whole.
/// </summary>
[SerializeField]
private RectOffset margin;
/// <summary>
/// The current layout status.
/// </summary>
private GridLayoutResults results;
/// <summary>
/// The rows in this panel.
/// </summary>
[SerializeField]
private IList<GridRowSpec> rows;
#pragma warning restore IDE0044
internal PGridLayoutGroup() {
children = new List<GridComponent<GameObject>>(16);
columns = new List<GridColumnSpec>(16);
rows = new List<GridRowSpec>(16);
layoutPriority = 1;
Margin = null;
results = null;
}
/// <summary>
/// Adds a column to this grid layout.
/// </summary>
/// <param name="column">The specification for that column.</param>
public void AddColumn(GridColumnSpec column) {
if (column == null)
throw new ArgumentNullException(nameof(column));
columns.Add(column);
}
/// <summary>
/// Adds a component to this layout. Components added through other means to the
/// transform will not be laid out at all!
/// </summary>
/// <param name="child">The child to add.</param>
/// <param name="spec">The location where the child will be placed.</param>
public void AddComponent(GameObject child, GridComponentSpec spec) {
if (child == null)
throw new ArgumentNullException(nameof(child));
if (spec == null)
throw new ArgumentNullException(nameof(spec));
children.Add(new GridComponent<GameObject>(spec, child));
child.SetParent(gameObject);
}
/// <summary>
/// Adds a row to this grid layout.
/// </summary>
/// <param name="row">The specification for that row.</param>
public void AddRow(GridRowSpec row) {
if (row == null)
throw new ArgumentNullException(nameof(row));
rows.Add(row);
}
public override void CalculateLayoutInputHorizontal() {
if (!locked) {
results = new GridLayoutResults(rows, columns, children);
var elements = ListPool<Component, PGridLayoutGroup>.Allocate();
foreach (var component in results.Components) {
// Cache size of children
var obj = component.HorizontalSize.source;
var margin = component.Margin;
elements.Clear();
obj.GetComponents(elements);
var sz = PUIUtils.CalcSizes(obj, PanelDirection.Horizontal, elements);
if (!sz.ignore) {
// Add borders
int border = (margin == null) ? 0 : margin.left + margin.right;
sz.min += border;
sz.preferred += border;
}
component.HorizontalSize = sz;
}
elements.Recycle();
// Calculate columns sizes and our size
results.CalcBaseWidths();
float width = results.MinWidth;
if (Margin != null)
width += Margin.left + Margin.right;
minWidth = preferredWidth = width;
flexibleWidth = (results.TotalFlexWidth > 0.0f) ? 1.0f : 0.0f;
}
}
public override void CalculateLayoutInputVertical() {
if (results != null && !locked) {
var elements = ListPool<Component, PGridLayoutGroup>.Allocate();
foreach (var component in results.Components) {
// Cache size of children
var obj = component.VerticalSize.source;
var margin = component.Margin;
elements.Clear();
obj.GetComponents(elements);
var sz = PUIUtils.CalcSizes(obj, PanelDirection.Vertical, elements);
if (!sz.ignore) {
// Add borders
int border = (margin == null) ? 0 : margin.top + margin.bottom;
sz.min += border;
sz.preferred += border;
}
component.VerticalSize = sz;
}
elements.Recycle();
// Calculate row sizes and our size
results.CalcBaseHeights();
float height = results.MinHeight;
if (Margin != null)
height += Margin.bottom + Margin.top;
minHeight = preferredHeight = height;
flexibleHeight = (results.TotalFlexHeight > 0.0f) ? 1.0f : 0.0f;
}
}
protected override void OnDisable() {
base.OnDisable();
results = null;
}
protected override void OnEnable() {
base.OnEnable();
results = null;
}
public override void SetLayoutHorizontal() {
var obj = gameObject;
if (results != null && obj != null && results.Columns > 0 && !locked) {
float[] colX = GetColumnWidths(results, rectTransform.rect.width, Margin);
// All components lay out
var controllers = ListPool<ILayoutController, PGridLayoutGroup>.Allocate();
foreach (var component in results.Components)
if (SetFinalWidth(component, colX)) {
// Lay out all children
controllers.Clear();
component.HorizontalSize.source.GetComponents(controllers);
foreach (var controller in controllers)
controller.SetLayoutHorizontal();
}
controllers.Recycle();
}
}
public override void SetLayoutVertical() {
var obj = gameObject;
if (results != null && obj != null && results.Rows > 0 && !locked) {
float[] rowY = GetRowHeights(results, rectTransform.rect.height, Margin);
// All components lay out
var controllers = ListPool<ILayoutController, PGridLayoutGroup>.Allocate();
foreach (var component in results.Components)
if (!SetFinalHeight(component, rowY)) {
// Lay out all children
controllers.Clear();
component.VerticalSize.source.GetComponents(controllers);
foreach (var controller in controllers)
controller.SetLayoutVertical();
}
controllers.Recycle();
}
}
}
}

View File

@@ -0,0 +1,292 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.UI.Layouts;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A layout group based on the constraints defined in RelativeLayout. Allows the same
/// fast relative positioning that RelativeLayout does, but can respond to changes in the
/// size of its containing components.
/// </summary>
public sealed class RelativeLayoutGroup : AbstractLayoutGroup,
ISerializationCallbackReceiver {
/// <summary>
/// The margin added around all components in the layout. This is in addition to any
/// margins around the components.
///
/// Note that this margin is not taken into account with percentage based anchors.
/// Items anchored to the extremes will always work fine. Items anchored in the middle
/// will use the middle <b>before</b> margins are effective.
/// </summary>
public RectOffset Margin {
get {
return margin;
}
set {
margin = value;
}
}
/// <summary>
/// Constraints for each object are stored here.
/// </summary>
private readonly IDictionary<GameObject, RelativeLayoutParams> locConstraints;
/// <summary>
/// The serialized constraints.
/// </summary>
[SerializeField]
private IList<KeyValuePair<GameObject, RelativeLayoutParams>> serialConstraints;
/// <summary>
/// The margin around the components as a whole.
/// </summary>
[SerializeField]
private RectOffset margin;
/// <summary>
/// The results of the layout in progress.
/// </summary>
private readonly IList<RelativeLayoutResults> results;
internal RelativeLayoutGroup() {
layoutPriority = 1;
locConstraints = new Dictionary<GameObject, RelativeLayoutParams>(32);
results = new List<RelativeLayoutResults>(32);
serialConstraints = null;
}
/// <summary>
/// Retrieves the parameters for a child game object. Creates an entry if none exists
/// for this component.
/// </summary>
/// <param name="item">The item to look up.</param>
/// <returns>The parameters for that object.</returns>
private RelativeLayoutParams AddOrGet(GameObject item) {
if (!locConstraints.TryGetValue(item, out RelativeLayoutParams param))
locConstraints[item] = param = new RelativeLayoutParams();
return param;
}
public RelativeLayoutGroup AnchorXAxis(GameObject item, float anchor = 0.5f) {
SetLeftEdge(item, fraction: anchor);
return SetRightEdge(item, fraction: anchor);
}
public RelativeLayoutGroup AnchorYAxis(GameObject item, float anchor = 0.5f) {
SetTopEdge(item, fraction: anchor);
return SetBottomEdge(item, fraction: anchor);
}
public override void CalculateLayoutInputHorizontal() {
var all = gameObject?.rectTransform();
if (all != null && !locked) {
int ml, mr, passes, limit;
if (Margin == null)
ml = mr = 0;
else {
ml = Margin.left;
mr = Margin.right;
}
// X layout
results.CalcX(all, locConstraints);
if (results.Count > 0) {
limit = 2 * results.Count;
for (passes = 0; passes < limit && !results.RunPassX(); passes++) ;
if (passes >= limit)
results.ThrowUnresolvable(passes, PanelDirection.Horizontal);
}
minWidth = preferredWidth = results.GetMinSizeX() + ml + mr;
}
}
public override void CalculateLayoutInputVertical() {
var all = gameObject?.rectTransform();
if (all != null && !locked) {
int passes, limit = 2 * results.Count, mt, mb;
if (Margin == null)
mt = mb = 0;
else {
mt = Margin.top;
mb = Margin.bottom;
}
// Y layout
if (results.Count > 0) {
results.CalcY();
for (passes = 0; passes < limit && !results.RunPassY(); passes++) ;
if (passes >= limit)
results.ThrowUnresolvable(passes, PanelDirection.Vertical);
}
minHeight = preferredHeight = results.GetMinSizeY() + mt + mb;
}
}
/// <summary>
/// Imports the data from RelativeLayout for compatibility.
/// </summary>
/// <param name="values">The raw data to import.</param>
internal void Import(IDictionary<GameObject, RelativeLayoutParams> values) {
locConstraints.Clear();
foreach (var pair in values)
locConstraints[pair.Key] = pair.Value;
}
public void OnBeforeSerialize() {
int n = locConstraints.Count;
if (n > 0) {
serialConstraints = new List<KeyValuePair<GameObject, RelativeLayoutParams>>(n);
foreach (var pair in locConstraints)
serialConstraints.Add(pair);
}
}
public void OnAfterDeserialize() {
if (serialConstraints != null) {
locConstraints.Clear();
foreach (var pair in serialConstraints)
locConstraints[pair.Key] = pair.Value;
serialConstraints = null;
}
}
public RelativeLayoutGroup OverrideSize(GameObject item, Vector2 size) {
if (item != null)
AddOrGet(item).OverrideSize = size;
return this;
}
public RelativeLayoutGroup SetBottomEdge(GameObject item, float fraction = -1.0f,
GameObject above = null) {
if (item != null) {
if (above == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(AddOrGet(item).BottomEdge, fraction, above);
}
return this;
}
protected override void SetDirty() {
if (!locked)
base.SetDirty();
}
/// <summary>
/// Sets a component's edge constraint.
/// </summary>
/// <param name="edge">The edge to set.</param>
/// <param name="fraction">The fraction of the parent to anchor.</param>
/// <param name="child">The other component to anchor.</param>
private void SetEdge(RelativeLayoutParams.EdgeStatus edge, float fraction,
GameObject child) {
if (fraction >= 0.0f && fraction <= 1.0f) {
edge.Constraint = RelativeConstraintType.ToAnchor;
edge.FromAnchor = fraction;
edge.FromComponent = null;
} else if (child != null) {
edge.Constraint = RelativeConstraintType.ToComponent;
edge.FromComponent = child;
} else {
edge.Constraint = RelativeConstraintType.Unconstrained;
edge.FromComponent = null;
}
}
public override void SetLayoutHorizontal() {
if (!locked && results.Count > 0) {
var components = ListPool<ILayoutController, RelativeLayoutGroup>.Allocate();
int ml, mr;
if (Margin == null)
ml = mr = 0;
else {
ml = Margin.left;
mr = Margin.right;
}
// Lay out children
results.ExecuteX(components, ml, mr);
components.Recycle();
}
}
public override void SetLayoutVertical() {
if (!locked && results.Count > 0) {
var components = ListPool<ILayoutController, RelativeLayoutGroup>.Allocate();
int mt, mb;
if (Margin == null)
mt = mb = 0;
else {
mt = Margin.top;
mb = Margin.bottom;
}
// Lay out children
results.ExecuteY(components, mt, mb);
components.Recycle();
}
}
public RelativeLayoutGroup SetLeftEdge(GameObject item, float fraction = -1.0f,
GameObject toRight = null) {
if (item != null) {
if (toRight == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(AddOrGet(item).LeftEdge, fraction, toRight);
}
return this;
}
public RelativeLayoutGroup SetMargin(GameObject item, RectOffset insets) {
if (item != null)
AddOrGet(item).Insets = insets;
return this;
}
/// <summary>
/// Sets all layout parameters of an object at once.
/// </summary>
/// <param name="item">The item to configure.</param>
/// <param name="rawParams">The raw parameters to use.</param>
internal void SetRaw(GameObject item, RelativeLayoutParams rawParams) {
if (item != null && rawParams != null)
locConstraints[item] = rawParams;
}
public RelativeLayoutGroup SetRightEdge(GameObject item, float fraction = -1.0f,
GameObject toLeft = null) {
if (item != null) {
if (toLeft == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(AddOrGet(item).RightEdge, fraction, toLeft);
}
return this;
}
public RelativeLayoutGroup SetTopEdge(GameObject item, float fraction = -1.0f,
GameObject below = null) {
if (item != null) {
if (below == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(AddOrGet(item).TopEdge, fraction, below);
}
return this;
}
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using UnityEngine;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// Stores constraints applied to a game object in a relative layout.
/// </summary>
[Serializable]
internal sealed class RelativeLayoutParams : RelativeLayoutParamsBase<GameObject> { }
/// <summary>
/// Stores constraints applied to an object in a relative layout.
/// </summary>
/// <typeparam name="T">The type of the target object.</typeparam>
internal class RelativeLayoutParamsBase<T> {
/// <summary>
/// The anchored position of the bottom edge.
/// </summary>
internal EdgeStatus BottomEdge { get; }
/// <summary>
/// The insets. If null, insets are all zero.
/// </summary>
internal RectOffset Insets { get; set; }
/// <summary>
/// The anchored position of the left edge.
/// </summary>
internal EdgeStatus LeftEdge { get; }
/// <summary>
/// Overrides the size of the component if set.
/// </summary>
internal Vector2 OverrideSize { get; set; }
/// <summary>
/// The anchored position of the right edge.
/// </summary>
internal EdgeStatus RightEdge { get; }
/// <summary>
/// The anchored position of the top edge.
/// </summary>
internal EdgeStatus TopEdge { get; }
internal RelativeLayoutParamsBase() {
Insets = null;
BottomEdge = new EdgeStatus();
LeftEdge = new EdgeStatus();
RightEdge = new EdgeStatus();
TopEdge = new EdgeStatus();
OverrideSize = Vector2.zero;
}
/// <summary>
/// The edge position determined for a component.
/// </summary>
[Serializable]
internal sealed class EdgeStatus {
/// <summary>
/// The type of constraint to use for this relative layout.
/// </summary>
internal RelativeConstraintType Constraint;
/// <summary>
/// The anchor position in the component that sets the relative anchor.
///
/// 0.0f is the bottom/left, 1.0f is the top/right.
/// </summary>
internal float FromAnchor;
/// <summary>
/// The component to which this edge is anchored.
/// </summary>
internal T FromComponent;
/// <summary>
/// The offset in pixels from the anchor. + is upwards/rightwards, - is downwards/
/// leftwards.
/// </summary>
internal float Offset;
/// <summary>
/// True if the position has been locked down in the code.
/// Locked should only be set by the layout manager, crashes may occur otherwise.
/// </summary>
public bool Locked {
get {
return Constraint == RelativeConstraintType.Locked;
}
}
/// <summary>
/// True if the position is not constrained to anything.
/// </summary>
public bool Unconstrained {
get {
return Constraint == RelativeConstraintType.Unconstrained;
}
}
internal EdgeStatus() {
FromAnchor = 0.0f;
FromComponent = default;
Offset = 0.0f;
Constraint = RelativeConstraintType.Unconstrained;
}
/// <summary>
/// Copies data from another edge status object.
/// </summary>
/// <param name="other">The object to copy.</param>
internal void CopyFrom(EdgeStatus other) {
if (other == null)
throw new ArgumentNullException(nameof(other));
switch (Constraint = other.Constraint) {
case RelativeConstraintType.ToComponent:
FromComponent = other.FromComponent;
break;
case RelativeConstraintType.ToAnchor:
FromAnchor = other.FromAnchor;
break;
case RelativeConstraintType.Locked:
FromAnchor = other.FromAnchor;
Offset = other.Offset;
break;
case RelativeConstraintType.Unconstrained:
default:
break;
}
}
public override bool Equals(object obj) {
// Caution: floats are imprecise
return obj is EdgeStatus other && other.FromAnchor == FromAnchor && other.
Offset == Offset && Constraint == other.Constraint;
}
public override int GetHashCode() {
return 37 * (37 * FromAnchor.GetHashCode() + Offset.GetHashCode()) +
Constraint.GetHashCode();
}
/// <summary>
/// Resets these offsets to unlocked.
/// </summary>
public void Reset() {
Constraint = RelativeConstraintType.Unconstrained;
Offset = 0.0f;
FromAnchor = 0.0f;
FromComponent = default;
}
public override string ToString() {
return string.Format("EdgeStatus[Constraints={2},Anchor={0:F2},Offset={1:F2}]",
FromAnchor, Offset, Constraint);
}
}
}
/// <summary>
/// The types of constraints which can be applied to components in a relative layout.
/// </summary>
internal enum RelativeConstraintType {
Unconstrained, ToComponent, ToAnchor, Locked
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using UnityEngine;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// Parameters used to store the dynamic data of an object during a relative layout.
/// </summary>
internal sealed class RelativeLayoutResults : RelativeLayoutParamsBase<GameObject> {
/// <summary>
/// A set of insets that are always zero.
/// </summary>
private static readonly RectOffset ZERO = new RectOffset();
/// <summary>
/// The instance parameters of the bottom edge's component.
/// </summary>
internal RelativeLayoutResults BottomParams { get; set; }
/// <summary>
/// The height of the component plus its margin box.
/// </summary>
internal float EffectiveHeight { get; private set; }
/// <summary>
/// The width of the component plus its margin box.
/// </summary>
internal float EffectiveWidth { get; private set; }
/// <summary>
/// The instance parameters of the left edge's component.
/// </summary>
internal RelativeLayoutResults LeftParams { get; set; }
/// <summary>
/// The preferred height at which this component will be laid out, unless both
/// edges are constrained.
/// </summary>
internal float PreferredHeight {
get {
return prefSize.y;
}
set {
prefSize.y = value;
EffectiveHeight = value + Insets.top + Insets.bottom;
}
}
/// <summary>
/// The preferred width at which this component will be laid out, unless both
/// edges are constrained.
/// </summary>
internal float PreferredWidth {
get {
return prefSize.x;
}
set {
prefSize.x = value;
EffectiveWidth = value + Insets.left + Insets.right;
}
}
/// <summary>
/// The instance parameters of the right edge's component.
/// </summary>
internal RelativeLayoutResults RightParams { get; set; }
/// <summary>
/// The instance parameters of the top edge's component.
/// </summary>
internal RelativeLayoutResults TopParams { get; set; }
/// <summary>
/// The object to lay out.
/// </summary>
internal RectTransform Transform { get; set; }
/// <summary>
/// Whether the size delta should be used in the X direction (as opposed to offsets).
/// </summary>
internal bool UseSizeDeltaX { get; set; }
/// <summary>
/// Whether the size delta should be used in the Y direction (as opposed to offsets).
/// </summary>
internal bool UseSizeDeltaY { get; set; }
/// <summary>
/// The preferred size of this component.
/// </summary>
private Vector2 prefSize;
internal RelativeLayoutResults(RectTransform transform, RelativeLayoutParams other) {
Transform = transform ?? throw new ArgumentNullException(nameof(transform));
if (other != null) {
BottomEdge.CopyFrom(other.BottomEdge);
TopEdge.CopyFrom(other.TopEdge);
RightEdge.CopyFrom(other.RightEdge);
LeftEdge.CopyFrom(other.LeftEdge);
Insets = other.Insets ?? ZERO;
OverrideSize = other.OverrideSize;
} else
Insets = ZERO;
BottomParams = LeftParams = TopParams = RightParams = null;
PreferredWidth = PreferredHeight = 0.0f;
UseSizeDeltaX = UseSizeDeltaY = false;
}
public override string ToString() {
var go = Transform.gameObject;
return string.Format("component={0} {1:F2}x{2:F2}", (go == null) ? "null" : go.
name, prefSize.x, prefSize.y);
}
}
}

View File

@@ -0,0 +1,401 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using EdgeStatus = PeterHan.PLib.UI.Layouts.RelativeLayoutParams.EdgeStatus;
namespace PeterHan.PLib.UI.Layouts {
/// <summary>
/// A helper class for RelativeLayout.
/// </summary>
internal static class RelativeLayoutUtil {
/// <summary>
/// Initializes and computes horizontal sizes for the components in this relative
/// layout.
/// </summary>
/// <param name="children">The location to store information about these components.</param>
/// <param name="all">The components to lay out.</param>
/// <param name="constraints">The constraints defined for these components.</param>
internal static void CalcX(this ICollection<RelativeLayoutResults> children,
RectTransform all, IDictionary<GameObject, RelativeLayoutParams> constraints) {
var comps = ListPool<Component, RelativeLayoutGroup>.Allocate();
var paramMap = DictionaryPool<GameObject, RelativeLayoutResults,
RelativeLayoutGroup>.Allocate();
int n = all.childCount;
children.Clear();
for (int i = 0; i < n; i++) {
var child = all.GetChild(i)?.gameObject;
if (child != null) {
comps.Clear();
// Calculate the preferred size using all layout components
child.GetComponents(comps);
var horiz = PUIUtils.CalcSizes(child, PanelDirection.Horizontal, comps);
if (!horiz.ignore) {
RelativeLayoutResults ip;
float w = horiz.preferred;
if (constraints.TryGetValue(child, out RelativeLayoutParams cons)) {
ip = new RelativeLayoutResults(child.rectTransform(), cons);
// Set override size by axis if necessary
var overrideSize = ip.OverrideSize;
if (overrideSize.x > 0.0f)
w = overrideSize.x;
paramMap[child] = ip;
} else
// Default its layout to fill all
ip = new RelativeLayoutResults(child.rectTransform(), null);
ip.PreferredWidth = w;
children.Add(ip);
}
}
}
// Resolve object references to other children
foreach (var ip in children) {
ip.TopParams = InitResolve(ip.TopEdge, paramMap);
ip.BottomParams = InitResolve(ip.BottomEdge, paramMap);
ip.LeftParams = InitResolve(ip.LeftEdge, paramMap);
ip.RightParams = InitResolve(ip.RightEdge, paramMap);
// All of these will die simultaneously when the list is recycled
}
paramMap.Recycle();
comps.Recycle();
}
/// <summary>
/// Computes vertical sizes for the components in this relative layout.
/// </summary>
/// <param name="children">The location to store information about these components.</param>
internal static void CalcY(this ICollection<RelativeLayoutResults> children) {
var comps = ListPool<Component, RelativeLayoutGroup>.Allocate();
foreach (var ip in children) {
var child = ip.Transform.gameObject;
var overrideSize = ip.OverrideSize;
comps.Clear();
// Calculate the preferred size using all layout components
child.gameObject.GetComponents(comps);
float h = PUIUtils.CalcSizes(child, PanelDirection.Vertical, comps).preferred;
// Set override size by axis if necessary
if (overrideSize.y > 0.0f)
h = overrideSize.y;
ip.PreferredHeight = h;
}
comps.Recycle();
}
/// <summary>
/// Calculates the minimum size the component must be to support a specific child
/// component.
/// </summary>
/// <param name="min">The lower edge constraint.</param>
/// <param name="max">The upper edge constraint.</param>
/// <param name="effective">The component size in that dimension plus margins.</param>
/// <returns>The minimum parent component size to fit the child.</returns>
internal static float ElbowRoom(EdgeStatus min, EdgeStatus max, float effective) {
float aMin = min.FromAnchor, aMax = max.FromAnchor, result, offMin = min.Offset,
offMax = max.Offset;
if (aMax > aMin)
// "Elbow room" method
result = (effective + offMin - offMax) / (aMax - aMin);
else
// Anchors are together
result = Math.Max(effective, Math.Max(Math.Abs(offMin), Math.Abs(offMax)));
return result;
}
/// <summary>
/// Executes the horizontal layout.
/// </summary>
/// <param name="children">The components to lay out.</param>
/// <param name="scratch">The location where components will be temporarily stored.</param>
/// <param name="mLeft">The left margin.</param>
/// <param name="mRight">The right margin.</param>
internal static void ExecuteX(this IEnumerable<RelativeLayoutResults> children,
List<ILayoutController> scratch, float mLeft = 0.0f, float mRight = 0.0f) {
foreach (var child in children) {
var rt = child.Transform;
var insets = child.Insets;
EdgeStatus l = child.LeftEdge, r = child.RightEdge;
// Set corner positions
rt.anchorMin = new Vector2(l.FromAnchor, 0.0f);
rt.anchorMax = new Vector2(r.FromAnchor, 1.0f);
// If anchored by pivot, resize with current anchors
if (child.UseSizeDeltaX)
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, child.
PreferredWidth);
else {
// Left
rt.offsetMin = new Vector2(l.Offset + insets.left + (l.FromAnchor <= 0.0f ?
mLeft : 0.0f), rt.offsetMin.y);
// Right
rt.offsetMax = new Vector2(r.Offset - insets.right - (r.FromAnchor >=
1.0f ? mRight : 0.0f), rt.offsetMax.y);
}
// Execute layout controllers if present
scratch.Clear();
rt.gameObject.GetComponents(scratch);
foreach (var component in scratch)
component.SetLayoutHorizontal();
}
}
/// <summary>
/// Executes the vertical layout.
/// </summary>
/// <param name="children">The components to lay out.</param>
/// <param name="scratch">The location where components will be temporarily stored.</param>
/// <param name="mBottom">The bottom margin.</param>
/// <param name="mTop">The top margin.</param>
internal static void ExecuteY(this IEnumerable<RelativeLayoutResults> children,
List<ILayoutController> scratch, float mBottom = 0.0f, float mTop = 0.0f) {
foreach (var child in children) {
var rt = child.Transform;
var insets = child.Insets;
EdgeStatus t = child.TopEdge, b = child.BottomEdge;
// Set corner positions
rt.anchorMin = new Vector2(rt.anchorMin.x, b.FromAnchor);
rt.anchorMax = new Vector2(rt.anchorMax.x, t.FromAnchor);
// If anchored by pivot, resize with current anchors
if (child.UseSizeDeltaY)
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, child.
PreferredHeight);
else {
// Bottom
rt.offsetMin = new Vector2(rt.offsetMin.x, b.Offset + insets.bottom +
(b.FromAnchor <= 0.0f ? mBottom : 0.0f));
// Top
rt.offsetMax = new Vector2(rt.offsetMax.x, t.Offset - insets.top -
(t.FromAnchor >= 1.0f ? mTop : 0.0f));
}
// Execute layout controllers if present
scratch.Clear();
rt.gameObject.GetComponents(scratch);
foreach (var component in scratch)
component.SetLayoutVertical();
}
}
/// <summary>
/// Calculates the minimum size in the X direction.
/// </summary>
/// <param name="children">The components to lay out.</param>
/// <returns>The minimum horizontal size.</returns>
internal static float GetMinSizeX(this IEnumerable<RelativeLayoutResults> children) {
float maxWidth = 0.0f;
foreach (var child in children) {
float width = ElbowRoom(child.LeftEdge, child.RightEdge, child.EffectiveWidth);
if (width > maxWidth) maxWidth = width;
}
return maxWidth;
}
/// <summary>
/// Calculates the minimum size in the Y direction.
/// </summary>
/// <param name="children">The components to lay out.</param>
/// <returns>The minimum vertical size.</returns>
internal static float GetMinSizeY(this IEnumerable<RelativeLayoutResults> children) {
float maxHeight = 0.0f;
foreach (var child in children) {
float height = ElbowRoom(child.BottomEdge, child.TopEdge, child.
EffectiveHeight);
if (height > maxHeight) maxHeight = height;
}
return maxHeight;
}
/// <summary>
/// Resolves a component reference if needed.
/// </summary>
/// <param name="edge">The edge to resolve.</param>
/// <param name="lookup">The location where the component can be looked up.</param>
/// <returns>The linked parameters for that edge if needed.</returns>
private static RelativeLayoutResults InitResolve(EdgeStatus edge,
IDictionary<GameObject, RelativeLayoutResults> lookup) {
RelativeLayoutResults result = null;
if (edge.Constraint == RelativeConstraintType.ToComponent)
if (!lookup.TryGetValue(edge.FromComponent, out result))
edge.Constraint = RelativeConstraintType.Unconstrained;
return result;
}
/// <summary>
/// Locks both edges if they are constrained to the same anchor.
/// </summary>
/// <param name="edge">The edge to check.</param>
/// <param name="otherEdge">The other edge to check.</param>
/// <returns>true if it was able to lock, or false otherwise.</returns>
private static bool LockEdgeAnchor(EdgeStatus edge, EdgeStatus otherEdge) {
bool useDelta = edge.Constraint == RelativeConstraintType.ToAnchor && otherEdge.
Constraint == RelativeConstraintType.ToAnchor && edge.FromAnchor == otherEdge.
FromAnchor;
if (useDelta) {
edge.Constraint = RelativeConstraintType.Locked;
otherEdge.Constraint = RelativeConstraintType.Locked;
edge.Offset = 0.0f;
otherEdge.Offset = 0.0f;
}
return useDelta;
}
/// <summary>
/// Locks an edge if it is constrained to an anchor.
/// </summary>
/// <param name="edge">The edge to check.</param>
private static void LockEdgeAnchor(EdgeStatus edge) {
if (edge.Constraint == RelativeConstraintType.ToAnchor) {
edge.Constraint = RelativeConstraintType.Locked;
edge.Offset = 0.0f;
}
}
/// <summary>
/// Locks an edge if it can be determined from another component.
/// </summary>
/// <param name="edge">The edge to check.</param>
/// <param name="offset">The component's offset in that direction.</param>
/// <param name="otherEdge">The opposing edge of the referenced component.</param>
private static void LockEdgeComponent(EdgeStatus edge, EdgeStatus otherEdge) {
if (edge.Constraint == RelativeConstraintType.ToComponent && otherEdge.Locked) {
edge.Constraint = RelativeConstraintType.Locked;
edge.FromAnchor = otherEdge.FromAnchor;
edge.Offset = otherEdge.Offset;
}
}
/// <summary>
/// Locks an edge if it can be determined from the other edge.
/// </summary>
/// <param name="edge">The edge to check.</param>
/// <param name="size">The component's effective size in that direction.</param>
/// <param name="opposing">The component's other edge.</param>
private static void LockEdgeRelative(EdgeStatus edge, float size, EdgeStatus opposing)
{
if (edge.Constraint == RelativeConstraintType.Unconstrained) {
if (opposing.Locked) {
edge.Constraint = RelativeConstraintType.Locked;
edge.FromAnchor = opposing.FromAnchor;
edge.Offset = opposing.Offset + size;
} else if (opposing.Constraint == RelativeConstraintType.Unconstrained) {
// Both unconstrained, full size
edge.Constraint = RelativeConstraintType.Locked;
edge.FromAnchor = 0.0f;
edge.Offset = 0.0f;
opposing.Constraint = RelativeConstraintType.Locked;
opposing.FromAnchor = 1.0f;
opposing.Offset = 0.0f;
}
}
}
/// <summary>
/// Runs a layout pass in the X direction, resolving edges that can be resolved.
/// </summary>
/// <param name="children">The children to resolve.</param>
/// <returns>true if all children have all X edges constrained, or false otherwise.</returns>
internal static bool RunPassX(this IEnumerable<RelativeLayoutResults> children) {
bool done = true;
foreach (var child in children) {
float width = child.EffectiveWidth;
EdgeStatus l = child.LeftEdge, r = child.RightEdge;
if (LockEdgeAnchor(l, r))
child.UseSizeDeltaX = true;
LockEdgeAnchor(l);
LockEdgeAnchor(r);
LockEdgeRelative(l, -width, r);
LockEdgeRelative(r, width, l);
// Lock to other components
if (child.LeftParams != null)
LockEdgeComponent(l, child.LeftParams.RightEdge);
if (child.RightParams != null)
LockEdgeComponent(r, child.RightParams.LeftEdge);
if (!l.Locked || !r.Locked) done = false;
}
return done;
}
/// <summary>
/// Runs a layout pass in the Y direction, resolving edges that can be resolved.
/// </summary>
/// <param name="children">The children to resolve.</param>
/// <returns>true if all children have all Y edges constrained, or false otherwise.</returns>
internal static bool RunPassY(this IEnumerable<RelativeLayoutResults> children) {
bool done = true;
foreach (var child in children) {
float height = child.EffectiveHeight;
EdgeStatus t = child.TopEdge, b = child.BottomEdge;
if (LockEdgeAnchor(t, b))
child.UseSizeDeltaY = true;
LockEdgeAnchor(b);
LockEdgeAnchor(t);
LockEdgeRelative(b, -height, t);
LockEdgeRelative(t, height, b);
// Lock to other components
if (child.BottomParams != null)
LockEdgeComponent(b, child.BottomParams.TopEdge);
if (child.TopParams != null)
LockEdgeComponent(t, child.TopParams.BottomEdge);
if (!t.Locked || !b.Locked) done = false;
}
return done;
}
/// <summary>
/// Throws an error when resolution fails.
/// </summary>
/// <param name="children">The children, some of which failed to resolve.</param>
/// <param name="limit">The number of passes executed before failing.</param>
/// <param name="direction">The direction that failed.</param>
internal static void ThrowUnresolvable(this IEnumerable<RelativeLayoutResults> children,
int limit, PanelDirection direction) {
var message = new StringBuilder(256);
message.Append("After ").Append(limit);
message.Append(" passes, unable to complete resolution of RelativeLayout:");
foreach (var child in children) {
string name = child.Transform.gameObject.name;
if (direction == PanelDirection.Horizontal) {
if (!child.LeftEdge.Locked) {
message.Append(' ');
message.Append(name);
message.Append(".Left");
}
if (!child.RightEdge.Locked) {
message.Append(' ');
message.Append(name);
message.Append(".Right");
}
} else {
if (!child.BottomEdge.Locked) {
message.Append(' ');
message.Append(name);
message.Append(".Bottom");
}
if (!child.TopEdge.Locked) {
message.Append(' ');
message.Append(name);
message.Append(".Top");
}
}
}
throw new InvalidOperationException(message.ToString());
}
}
}

173
mod/PLibUI/PButton.cs Normal file
View File

@@ -0,0 +1,173 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI button factory class.
/// </summary>
public class PButton : PTextComponent {
/// <summary>
/// The default margins around a button.
/// </summary>
internal static readonly RectOffset BUTTON_MARGIN = new RectOffset(7, 7, 5, 5);
/// <summary>
/// Sets up the button to have the right sound and background image.
/// </summary>
/// <param name="button">The button to set up.</param>
/// <param name="bgImage">The background image.</param>
internal static void SetupButton(KButton button, KImage bgImage) {
UIDetours.ADDITIONAL_K_IMAGES.Set(button, new KImage[0]);
UIDetours.SOUND_PLAYER_BUTTON.Set(button, PUITuning.ButtonSounds);
UIDetours.BG_IMAGE.Set(button, bgImage);
}
/// <summary>
/// Sets up the background image to have the right sprite and slice type.
/// </summary>
/// <param name="bgImage">The image that forms the button background.</param>
internal static void SetupButtonBackground(KImage bgImage) {
UIDetours.APPLY_COLOR_STYLE.Invoke(bgImage);
bgImage.sprite = PUITuning.Images.ButtonBorder;
bgImage.type = Image.Type.Sliced;
}
/// <summary>
/// Enables or disables a realized button.
/// </summary>
/// <param name="obj">The realized button object.</param>
/// <param name="enabled">true to make it enabled, or false to make it disabled (greyed out).</param>
public static void SetButtonEnabled(GameObject obj, bool enabled) {
if (obj != null && obj.TryGetComponent(out KButton button))
UIDetours.IS_INTERACTABLE.Set(button, enabled);
}
/// <summary>
/// The button's background color.
/// </summary>
public ColorStyleSetting Color { get; set; }
/// <summary>
/// The action to trigger on click. It is passed the realized source object.
/// </summary>
public PUIDelegates.OnButtonPressed OnClick { get; set; }
public PButton() : this(null) { }
public PButton(string name) : base(name ?? "Button") {
Margin = BUTTON_MARGIN;
Sprite = null;
Text = null;
ToolTip = "";
}
/// <summary>
/// Adds a handler when this button is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This button for call chaining.</returns>
public PButton AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public override GameObject Build() {
var button = PUIElements.CreateUI(null, Name);
GameObject sprite = null, text = null;
// Background
var bgImage = button.AddComponent<KImage>();
var bgColorStyle = Color ?? PUITuning.Colors.ButtonPinkStyle;
UIDetours.COLOR_STYLE_SETTING.Set(bgImage, bgColorStyle);
SetupButtonBackground(bgImage);
// Set on click event
var kButton = button.AddComponent<KButton>();
var evt = OnClick;
if (evt != null)
// Detouring an Event is not worth the effort
kButton.onClick += () => evt?.Invoke(button);
SetupButton(kButton, bgImage);
// Add foreground image since the background already has one
if (Sprite != null) {
var fgImage = ImageChildHelper(button, this);
UIDetours.FG_IMAGE.Set(kButton, fgImage);
sprite = fgImage.gameObject;
}
// Add text
if (!string.IsNullOrEmpty(Text))
text = TextChildHelper(button, TextStyle ?? PUITuning.Fonts.UILightStyle,
Text).gameObject;
// Add tooltip
PUIElements.SetToolTip(button, ToolTip).SetActive(true);
// Arrange the icon and text
var layout = button.AddComponent<RelativeLayoutGroup>();
layout.Margin = Margin;
GameObject inner;
ArrangeComponent(layout, inner = WrapTextAndSprite(text, sprite), TextAlignment);
if (!DynamicSize) layout.LockLayout();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
DestroyLayoutIfPossible(button);
InvokeRealize(button);
return button;
}
/// <summary>
/// Sets the sprite to a leftward facing arrow. Beware the size, scale the button down!
/// </summary>
/// <returns>This button for call chaining.</returns>
public PButton SetImageLeftArrow() {
Sprite = PUITuning.Images.Arrow;
SpriteTransform = ImageTransform.FlipHorizontal;
return this;
}
/// <summary>
/// Sets the sprite to a rightward facing arrow. Beware the size, scale the button
/// down!
/// </summary>
/// <returns>This button for call chaining.</returns>
public PButton SetImageRightArrow() {
Sprite = PUITuning.Images.Arrow;
SpriteTransform = ImageTransform.None;
return this;
}
/// <summary>
/// Sets the default Klei pink button style as this button's color and text style.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PButton SetKleiPinkStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
Color = PUITuning.Colors.ButtonPinkStyle;
return this;
}
/// <summary>
/// Sets the default Klei blue button style as this button's color and text style.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PButton SetKleiBlueStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
Color = PUITuning.Colors.ButtonBlueStyle;
return this;
}
}
}

270
mod/PLibUI/PCheckBox.cs Normal file
View File

@@ -0,0 +1,270 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI button check box factory class.
/// </summary>
public class PCheckBox : PTextComponent {
/// <summary>
/// The border size between the checkbox border and icon.
/// </summary>
private const float CHECKBOX_MARGIN = 2.0f;
/// <summary>
/// The unchecked state.
/// </summary>
public const int STATE_UNCHECKED = 0;
/// <summary>
/// The checked state.
/// </summary>
public const int STATE_CHECKED = 1;
/// <summary>
/// The partially checked state.
/// </summary>
public const int STATE_PARTIAL = 2;
/// <summary>
/// Generates the checkbox image states.
/// </summary>
/// <param name="imageColor">The color style for the checked icon.</param>
/// <returns>The states for this checkbox.</returns>
private static ToggleState[] GenerateStates(ColorStyleSetting imageColor) {
// Structs sadly do not work well with detours
var sps = new StatePresentationSetting() {
color = imageColor.activeColor, use_color_on_hover = true,
color_on_hover = imageColor.hoverColor, image_target = null,
name = "Partial"
};
return new ToggleState[] {
new ToggleState() {
// Unchecked
color = PUITuning.Colors.Transparent, color_on_hover = PUITuning.Colors.
Transparent, sprite = null, use_color_on_hover = false,
additional_display_settings = new StatePresentationSetting[] {
new StatePresentationSetting() {
color = imageColor.activeColor, use_color_on_hover = false,
image_target = null, name = "Unchecked"
}
}
},
new ToggleState() {
// Checked
color = imageColor.activeColor, color_on_hover = imageColor.hoverColor,
sprite = PUITuning.Images.Checked, use_color_on_hover = true,
additional_display_settings = new StatePresentationSetting[] { sps }
},
new ToggleState() {
// Partial
color = imageColor.activeColor, color_on_hover = imageColor.hoverColor,
sprite = PUITuning.Images.Partial, use_color_on_hover = true,
additional_display_settings = new StatePresentationSetting[] { sps }
}
};
}
/// <summary>
/// Gets a realized check box's state.
/// </summary>
/// <param name="realized">The realized check box.</param>
/// <returns>The check box state.</returns>
public static int GetCheckState(GameObject realized) {
int state = STATE_UNCHECKED;
if (realized == null)
throw new ArgumentNullException(nameof(realized));
var checkButton = realized.GetComponentInChildren<MultiToggle>();
if (checkButton != null)
state = UIDetours.CURRENT_STATE.Get(checkButton);
return state;
}
/// <summary>
/// Sets a realized check box's state.
/// </summary>
/// <param name="realized">The realized check box.</param>
/// <param name="state">The new state to set.</param>
public static void SetCheckState(GameObject realized, int state) {
if (realized == null)
throw new ArgumentNullException(nameof(realized));
var checkButton = realized.GetComponentInChildren<MultiToggle>();
if (checkButton != null && UIDetours.CURRENT_STATE.Get(checkButton) != state)
UIDetours.CHANGE_STATE.Invoke(checkButton, state);
}
/// <summary>
/// The check box color.
/// </summary>
public ColorStyleSetting CheckColor { get; set; }
/// <summary>
/// The check box's background color.
///
/// Unlike other components, this color applies only to the check box itself.
/// </summary>
public Color BackColor { get; set; }
/// <summary>
/// The size to scale the check box. If 0x0, it will not be scaled.
/// </summary>
public Vector2 CheckSize { get; set; }
/// <summary>
/// The background color of everything that is not the check box.
/// </summary>
public Color ComponentBackColor { get; set; }
/// <summary>
/// The initial check box state.
/// </summary>
public int InitialState { get; set; }
/// <summary>
/// The action to trigger on click. It is passed the realized source object.
/// </summary>
public PUIDelegates.OnChecked OnChecked { get; set; }
public PCheckBox() : this(null) { }
public PCheckBox(string name) : base(name ?? "CheckBox") {
BackColor = PUITuning.Colors.BackgroundLight;
CheckColor = null;
CheckSize = new Vector2(16.0f, 16.0f);
ComponentBackColor = PUITuning.Colors.Transparent;
IconSpacing = 3;
InitialState = STATE_UNCHECKED;
Sprite = null;
Text = null;
ToolTip = "";
}
/// <summary>
/// Adds a handler when this check box is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This check box for call chaining.</returns>
public PCheckBox AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public override GameObject Build() {
var checkbox = PUIElements.CreateUI(null, Name);
var actualSize = CheckSize;
GameObject sprite = null, text = null;
// Background
if (ComponentBackColor.a > 0)
checkbox.AddComponent<Image>().color = ComponentBackColor;
var trueColor = CheckColor ?? PUITuning.Colors.ComponentLightStyle;
// Checkbox background
var checkBG = PUIElements.CreateUI(checkbox, "CheckBox");
checkBG.AddComponent<Image>().color = BackColor;
var checkImage = CreateCheckImage(checkBG, trueColor, ref actualSize);
checkBG.SetUISize(new Vector2(actualSize.x + 2.0f * CHECKBOX_MARGIN,
actualSize.y + 2.0f * CHECKBOX_MARGIN), true);
// Add foreground image
if (Sprite != null)
sprite = ImageChildHelper(checkbox, this).gameObject;
// Add text
if (!string.IsNullOrEmpty(Text))
text = TextChildHelper(checkbox, TextStyle ?? PUITuning.Fonts.UILightStyle,
Text).gameObject;
// Toggle
var mToggle = checkbox.AddComponent<MultiToggle>();
var evt = OnChecked;
if (evt != null)
mToggle.onClick += () => evt?.Invoke(checkbox, mToggle.CurrentState);
UIDetours.PLAY_SOUND_CLICK.Set(mToggle, true);
UIDetours.PLAY_SOUND_RELEASE.Set(mToggle, false);
mToggle.states = GenerateStates(trueColor);
mToggle.toggle_image = checkImage;
UIDetours.CHANGE_STATE.Invoke(mToggle, InitialState);
PUIElements.SetToolTip(checkbox, ToolTip).SetActive(true);
// Faster than ever!
var subLabel = WrapTextAndSprite(text, sprite);
var layout = checkbox.AddComponent<RelativeLayoutGroup>();
layout.Margin = Margin;
ArrangeComponent(layout, WrapTextAndSprite(subLabel, checkBG), TextAlignment);
if (!DynamicSize) layout.LockLayout();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
DestroyLayoutIfPossible(checkbox);
InvokeRealize(checkbox);
return checkbox;
}
/// <summary>
/// Creates the actual image that shows the checkbox graphically.
/// </summary>
/// <param name="checkbox">The parent object to add the image.</param>
/// <param name="color">The color style for the box border.</param>
/// <param name="actualSize">The actual check mark size, which will be updated if it
/// is 0x0 to the default size.</param>
/// <returns>The image reference to the checkmark image itself.</returns>
private Image CreateCheckImage(GameObject checkbox, ColorStyleSetting color,
ref Vector2 actualSize) {
// Checkbox border (grr rule of only one Graphics per GO...)
var checkBorder = PUIElements.CreateUI(checkbox, "CheckBorder");
var borderImg = checkBorder.AddComponent<Image>();
borderImg.sprite = PUITuning.Images.CheckBorder;
borderImg.color = color.activeColor;
borderImg.type = Image.Type.Sliced;
// Checkbox foreground
var imageChild = PUIElements.CreateUI(checkbox, "CheckMark", true, PUIAnchoring.
Center, PUIAnchoring.Center);
var checkImage = imageChild.AddComponent<Image>();
checkImage.sprite = PUITuning.Images.Checked;
checkImage.preserveAspect = true;
// Determine the checkbox size
if (actualSize.x <= 0.0f || actualSize.y <= 0.0f) {
var rt = imageChild.rectTransform();
actualSize.x = LayoutUtility.GetPreferredWidth(rt);
actualSize.y = LayoutUtility.GetPreferredHeight(rt);
}
imageChild.SetUISize(CheckSize, false);
return checkImage;
}
/// <summary>
/// Sets the default Klei pink button style as this check box's color and text style.
/// </summary>
/// <returns>This check box for call chaining.</returns>
public PCheckBox SetKleiPinkStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
CheckColor = PUITuning.Colors.ComponentDarkStyle;
return this;
}
/// <summary>
/// Sets the default Klei blue button style as this check box's color and text style.
/// </summary>
/// <returns>This check box for call chaining.</returns>
public PCheckBox SetKleiBlueStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
CheckColor = PUITuning.Colors.ComponentDarkStyle;
return this;
}
}
}

315
mod/PLibUI/PComboBox.cs Normal file
View File

@@ -0,0 +1,315 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI combo box factory class.
/// </summary>
public sealed class PComboBox<T> : IUIComponent, IDynamicSizable where T : class,
IListableOption {
/// <summary>
/// The default margin around items in the pulldown.
/// </summary>
private static readonly RectOffset DEFAULT_ITEM_MARGIN = new RectOffset(3, 3, 3, 3);
/// <summary>
/// Sets the selected option in a realized combo box.
/// </summary>
/// <param name="realized">The realized combo box.</param>
/// <param name="option">The option to set.</param>
/// <param name="fireListener">true to fire the on select listener, or false otherwise.</param>
public static void SetSelectedItem(GameObject realized, IListableOption option,
bool fireListener = false) {
if (option != null && realized != null && realized.TryGetComponent(
out PComboBoxComponent component))
component.SetSelectedItem(option, fireListener);
}
/// <summary>
/// The size of the sprite used to expand/contract the options.
/// </summary>
public Vector2 ArrowSize { get; set; }
/// <summary>
/// The combo box's background color.
/// </summary>
public ColorStyleSetting BackColor { get; set; }
/// <summary>
/// The size of the check mark sprite used on the selected option.
/// </summary>
public Vector2 CheckSize { get; set; }
/// <summary>
/// The content of this combo box.
/// </summary>
public IEnumerable<T> Content { get; set; }
public bool DynamicSize { get; set; }
/// <summary>
/// The background color for each entry in the combo box pulldown.
/// </summary>
public ColorStyleSetting EntryColor { get; set; }
/// <summary>
/// The flexible size bounds of this combo box.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The initially selected item of the combo box.
/// </summary>
public T InitialItem { get; set; }
/// <summary>
/// The margin around each item in the pulldown.
/// </summary>
public RectOffset ItemMargin { get; set; }
/// <summary>
/// The margin around the component.
/// </summary>
public RectOffset Margin { get; set; }
/// <summary>
/// The maximum number of items to be shown at once before a scroll bar is added.
/// </summary>
public int MaxRowsShown { get; set; }
/// <summary>
/// The minimum width in units (not characters!) of this text field.
/// </summary>
public int MinWidth { get; set; }
public string Name { get; }
/// <summary>
/// The action to trigger when an item is selected. It is passed the realized source
/// object.
/// </summary>
public PUIDelegates.OnDropdownChanged<T> OnOptionSelected { get; set; }
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The text alignment in the combo box.
/// </summary>
public TextAnchor TextAlignment { get; set; }
/// <summary>
/// The combo box's text color, font, word wrap settings, and font size.
/// </summary>
public TextStyleSetting TextStyle { get; set; }
/// <summary>
/// The tool tip text.
/// </summary>
public string ToolTip { get; set; }
public PComboBox() : this("Dropdown") { }
public PComboBox(string name) {
ArrowSize = new Vector2(8.0f, 8.0f);
BackColor = null;
CheckSize = new Vector2(12.0f, 12.0f);
Content = null;
DynamicSize = false;
FlexSize = Vector2.zero;
InitialItem = null;
ItemMargin = DEFAULT_ITEM_MARGIN;
Margin = PButton.BUTTON_MARGIN;
MaxRowsShown = 6;
MinWidth = 0;
Name = name;
TextAlignment = TextAnchor.MiddleLeft;
TextStyle = null;
ToolTip = null;
}
/// <summary>
/// Adds a handler when this combo box is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This combo box for call chaining.</returns>
public PComboBox<T> AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
var combo = PUIElements.CreateUI(null, Name);
var style = TextStyle ?? PUITuning.Fonts.UILightStyle;
var entryColor = EntryColor ?? PUITuning.Colors.ButtonBlueStyle;
RectOffset margin = Margin, im = ItemMargin;
// Background color
var bgImage = combo.AddComponent<KImage>();
var backColorStyle = BackColor ?? PUITuning.Colors.ButtonBlueStyle;
UIDetours.COLOR_STYLE_SETTING.Set(bgImage, backColorStyle);
PButton.SetupButtonBackground(bgImage);
// Need a LocText (selected item)
var selection = PUIElements.CreateUI(combo, "SelectedItem");
if (MinWidth > 0)
selection.SetMinUISize(new Vector2(MinWidth, 0.0f));
var selectedLabel = PUIElements.AddLocText(selection, style);
// Vertical flow panel with the choices
var contentContainer = PUIElements.CreateUI(null, "Content");
contentContainer.AddComponent<VerticalLayoutGroup>().childForceExpandWidth = true;
// Scroll pane with items is laid out below everything else
var pullDown = new PScrollPane("PullDown") {
ScrollHorizontal = false, ScrollVertical = true, AlwaysShowVertical = true,
FlexSize = Vector2.right, TrackSize = 8.0f, BackColor = entryColor.
inactiveColor
}.BuildScrollPane(combo, contentContainer);
// Add a black border (Does not work, covered by the combo box buttons...)
#if false
var pdImage = pullDown.GetComponent<Image>();
pdImage.sprite = PUITuning.Images.BoxBorder;
pdImage.type = Image.Type.Sliced;
#endif
pullDown.rectTransform().pivot = new Vector2(0.5f, 1.0f);
// Initialize the drop down
var comboBox = combo.AddComponent<PComboBoxComponent>();
comboBox.CheckColor = style.textColor;
comboBox.ContentContainer = contentContainer.rectTransform();
comboBox.EntryPrefab = BuildRowPrefab(style, entryColor);
comboBox.MaxRowsShown = MaxRowsShown;
comboBox.Pulldown = pullDown;
comboBox.SelectedLabel = selectedLabel;
comboBox.SetItems(Content);
comboBox.SetSelectedItem(InitialItem);
comboBox.OnSelectionChanged = (obj, item) => OnOptionSelected?.Invoke(obj.
gameObject, item as T);
// Inner component with the pulldown image
var image = PUIElements.CreateUI(combo, "OpenImage");
var icon = image.AddComponent<Image>();
icon.sprite = PUITuning.Images.Contract;
icon.color = style.textColor;
// Button component
var dropButton = combo.AddComponent<KButton>();
PButton.SetupButton(dropButton, bgImage);
UIDetours.FG_IMAGE.Set(dropButton, icon);
dropButton.onClick += comboBox.OnClick;
// Add tooltip
PUIElements.SetToolTip(selection, ToolTip);
combo.SetActive(true);
// Button gets laid out on the right, rest of space goes to the label
// Scroll pane is laid out on the bottom
var layout = combo.AddComponent<RelativeLayoutGroup>();
layout.AnchorYAxis(selection).SetLeftEdge(selection, fraction:
0.0f).SetRightEdge(selection, toLeft: image).AnchorYAxis(image).SetRightEdge(
image, fraction: 1.0f).SetMargin(selection, new RectOffset(margin.left,
im.right, margin.top, margin.bottom)).SetMargin(image, new RectOffset(0,
margin.right, margin.top, margin.bottom)).OverrideSize(image, ArrowSize).
AnchorYAxis(pullDown, 0.0f).OverrideSize(pullDown, Vector2.up);
layout.LockLayout();
if (DynamicSize)
layout.UnlockLayout();
// Disable sizing on the pulldown
pullDown.AddOrGet<LayoutElement>().ignoreLayout = true;
// Scroll pane is hidden right away
pullDown.SetActive(false);
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
OnRealize?.Invoke(combo);
return combo;
}
/// <summary>
/// Builds a row selection prefab object for this combo box.
/// </summary>
/// <param name="style">The text style for the entries.</param>
/// <param name="entryColor">The color for the entry backgrounds.</param>
/// <returns>A template for each row in the dropdown.</returns>
private GameObject BuildRowPrefab(TextStyleSetting style, ColorStyleSetting entryColor)
{
var im = ItemMargin;
var rowPrefab = PUIElements.CreateUI(null, "RowEntry");
// Background of the entry
var bgImage = rowPrefab.AddComponent<KImage>();
UIDetours.COLOR_STYLE_SETTING.Set(bgImage, entryColor);
UIDetours.APPLY_COLOR_STYLE.Invoke(bgImage);
// Checkmark for the front of the entry
var isSelected = PUIElements.CreateUI(rowPrefab, "Selected");
var fgImage = isSelected.AddComponent<Image>();
fgImage.color = style.textColor;
fgImage.preserveAspect = true;
fgImage.sprite = PUITuning.Images.Checked;
// Button for the entry to select it
var entryButton = rowPrefab.AddComponent<KButton>();
PButton.SetupButton(entryButton, bgImage);
UIDetours.FG_IMAGE.Set(entryButton, fgImage);
// Tooltip for the entry
rowPrefab.AddComponent<ToolTip>();
// Text for the entry
var textContainer = PUIElements.CreateUI(rowPrefab, "Text");
PUIElements.AddLocText(textContainer, style).SetText(" ");
// Configure the entire layout in 1 statement! (jk this is awful)
var group = rowPrefab.AddComponent<RelativeLayoutGroup>();
group.AnchorYAxis(isSelected).OverrideSize(isSelected, CheckSize).SetLeftEdge(
isSelected, fraction: 0.0f).SetMargin(isSelected, im).AnchorYAxis(
textContainer).SetLeftEdge(textContainer, toRight: isSelected).SetRightEdge(
textContainer, 1.0f).SetMargin(textContainer, new RectOffset(0, im.right,
im.top, im.bottom)).LockLayout();
rowPrefab.SetActive(false);
return rowPrefab;
}
/// <summary>
/// Sets the default Klei pink button style as this combo box's foreground color and text style.
/// </summary>
/// <returns>This combo box for call chaining.</returns>
public PComboBox<T> SetKleiPinkStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonPinkStyle;
return this;
}
/// <summary>
/// Sets the default Klei blue button style as this combo box's foreground color and text style.
/// </summary>
/// <returns>This combo box for call chaining.</returns>
public PComboBox<T> SetKleiBlueStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonBlueStyle;
return this;
}
/// <summary>
/// Sets the minimum (and preferred) width of this combo box in characters.
///
/// The width is computed using the currently selected text style.
/// </summary>
/// <param name="chars">The number of characters to be displayed.</param>
/// <returns>This combo box for call chaining.</returns>
public PComboBox<T> SetMinWidthInCharacters(int chars) {
int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle));
if (width > 0)
MinWidth = width;
return this;
}
public override string ToString() {
return string.Format("PComboBox[Name={0}]", Name);
}
}
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// An improved variant of DropDown/Dropdown.
/// </summary>
internal sealed class PComboBoxComponent : KMonoBehaviour {
/// <summary>
/// The container where the combo box items will be placed.
/// </summary>
internal RectTransform ContentContainer { get; set; }
/// <summary>
/// The color for the checkbox if it is selected.
/// </summary>
internal Color CheckColor { get; set; }
/// <summary>
/// The prefab used to display each row.
/// </summary>
internal GameObject EntryPrefab { get; set; }
/// <summary>
/// The maximum number of rows to be shown.
/// </summary>
internal int MaxRowsShown { get; set; }
/// <summary>
/// Called when an item is selected.
/// </summary>
internal Action<PComboBoxComponent, IListableOption> OnSelectionChanged { get; set; }
/// <summary>
/// The object which contains the pull down section of the combo box.
/// </summary>
internal GameObject Pulldown { get; set; }
/// <summary>
/// The selected label.
/// </summary>
internal TMP_Text SelectedLabel { get; set; }
/// <summary>
/// The items which are currently shown in this combo box.
/// </summary>
private readonly IList<ComboBoxItem> currentItems;
/// <summary>
/// The currently active mouse event handler, or null if not yet configured.
/// </summary>
private MouseEventHandler handler;
/// <summary>
/// Whether the combo box is expanded.
/// </summary>
private bool open;
internal PComboBoxComponent() {
CheckColor = Color.white;
currentItems = new List<ComboBoxItem>(32);
handler = null;
MaxRowsShown = 8;
open = false;
}
/// <summary>
/// Closes the pulldown. The selected choice is not saved.
/// </summary>
public void Close() {
Pulldown?.SetActive(false);
open = false;
}
/// <summary>
/// Triggered when the combo box is clicked.
/// </summary>
public void OnClick() {
if (open)
Close();
else
Open();
}
protected override void OnPrefabInit() {
base.OnPrefabInit();
handler = gameObject.AddOrGet<MouseEventHandler>();
}
/// <summary>
/// Opens the pulldown.
/// </summary>
public void Open() {
var pdn = Pulldown;
if (pdn != null) {
float rowHeight = 0.0f;
int itemCount = currentItems.Count, rows = Math.Min(MaxRowsShown, itemCount);
var canvas = pdn.AddOrGet<Canvas>();
pdn.SetActive(true);
// Calculate desired height of scroll pane
if (itemCount > 0) {
var rt = currentItems[0].rowInstance.rectTransform();
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);
rowHeight = LayoutUtility.GetPreferredHeight(rt);
}
// Update size and enable/disable scrolling if not needed
pdn.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical,
rows * rowHeight);
if (pdn.TryGetComponent(out ScrollRect sp))
sp.vertical = itemCount >= MaxRowsShown;
// Move above normal UI elements but below the tooltips
if (canvas != null) {
canvas.overrideSorting = true;
canvas.sortingOrder = 2;
}
}
open = true;
}
/// <summary>
/// Sets the items which will be shown in this combo box.
/// </summary>
/// <param name="items">The items to show.</param>
public void SetItems(IEnumerable<IListableOption> items) {
if (items != null) {
var content = ContentContainer;
var pdn = Pulldown;
int n = content.childCount;
var prefab = EntryPrefab;
bool wasOpen = open;
if (wasOpen)
Close();
// Destroy only destroys it at end of frame!
for (int i = 0; i < n; i++)
Destroy(content.GetChild(i));
currentItems.Clear();
foreach (var item in items) {
string tooltip = "";
var rowInstance = Util.KInstantiate(prefab, content.gameObject);
// Update the text shown
rowInstance.GetComponentInChildren<TextMeshProUGUI>().SetText(item.
GetProperName());
// Apply the listener for the button
var button = rowInstance.GetComponentInChildren<KButton>();
button.ClearOnClick();
button.onClick += () => {
SetSelectedItem(item, true);
Close();
};
// Assign the tooltip if possible
if (item is ITooltipListableOption extended)
tooltip = extended.GetToolTipText();
if (rowInstance.TryGetComponent(out ToolTip tt)) {
if (string.IsNullOrEmpty(tooltip))
tt.ClearMultiStringTooltip();
else
tt.SetSimpleTooltip(tooltip);
}
rowInstance.SetActive(true);
currentItems.Add(new ComboBoxItem(item, rowInstance));
}
SelectedLabel?.SetText((currentItems.Count > 0) ? currentItems[0].data.
GetProperName() : "");
if (wasOpen)
Open();
}
}
/// <summary>
/// Sets the selected item in the combo box.
/// </summary>
/// <param name="option">The option that was chosen.</param>
/// <param name="fireListener">true to also fire the option selected listener, or false otherwise.</param>
public void SetSelectedItem(IListableOption option, bool fireListener = false) {
if (option != null) {
SelectedLabel?.SetText(option.GetProperName());
// No guarantee that the options are hashable
foreach (var item in currentItems) {
var data = item.data;
// Show or hide the check mark next to the selected option
item.rowImage.color = (data != null && data.Equals(option)) ? CheckColor :
PUITuning.Colors.Transparent;
}
if (fireListener)
OnSelectionChanged?.Invoke(this, option);
}
}
/// <summary>
/// Called each frame by Unity, checks to see if the user clicks/scrolls outside of
/// the dropdown while open, and closes it if so.
/// </summary>
internal void Update() {
if (open && handler != null && !handler.IsOver && (Input.GetMouseButton(0) ||
Input.GetAxis("Mouse ScrollWheel") != 0.0f))
Close();
}
/// <summary>
/// The items in a combo box, paired with the game object owning that row and the
/// object that goes there.
/// </summary>
private struct ComboBoxItem {
public readonly IListableOption data;
public readonly Image rowImage;
public readonly GameObject rowInstance;
public ComboBoxItem(IListableOption data, GameObject rowInstance) {
this.data = data;
this.rowInstance = rowInstance;
rowImage = rowInstance.GetComponentInChildrenOnly<Image>();
}
}
/// <summary>
/// Handles mouse events on the pulldown.
/// </summary>
private sealed class MouseEventHandler : MonoBehaviour, IPointerEnterHandler,
IPointerExitHandler {
/// <summary>
/// Whether the mouse is over this component.
/// </summary>
public bool IsOver { get; private set; }
internal MouseEventHandler() {
IsOver = true;
}
public void OnPointerEnter(PointerEventData data) {
IsOver = true;
}
public void OnPointerExit(PointerEventData data) {
IsOver = false;
}
}
}
}

99
mod/PLibUI/PContainer.cs Normal file
View File

@@ -0,0 +1,99 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// The abstract parent of PLib UI objects that are meant to contain other UI objects.
/// </summary>
public abstract class PContainer : IUIComponent {
/// <summary>
/// The background color of this panel.
/// </summary>
public Color BackColor { get; set; }
/// <summary>
/// The background image of this panel. Tinted by the background color, acts as all
/// white if left null.
///
/// Note that the default background color is transparent, so unless it is set to
/// some other color this image will be invisible!
/// </summary>
public Sprite BackImage { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The mode to use when displaying the background image.
/// </summary>
public Image.Type ImageMode { get; set; }
/// <summary>
/// The margin left around the contained components in pixels. If null, no margin will
/// be used.
/// </summary>
public RectOffset Margin { get; set; }
public string Name { get; protected set; }
public event PUIDelegates.OnRealize OnRealize;
protected PContainer(string name) {
BackColor = PUITuning.Colors.Transparent;
BackImage = null;
FlexSize = Vector2.zero;
ImageMode = Image.Type.Simple;
Margin = null;
Name = name ?? "Container";
}
public abstract GameObject Build();
/// <summary>
/// Invokes the OnRealize event.
/// </summary>
/// <param name="obj">The realized text component.</param>
protected void InvokeRealize(GameObject obj) {
OnRealize?.Invoke(obj);
}
/// <summary>
/// Configures the background color and/or image for this panel.
/// </summary>
/// <param name="panel">The realized panel object.</param>
protected void SetImage(GameObject panel) {
if (BackColor.a > 0.0f || BackImage != null) {
var img = panel.AddComponent<Image>();
img.color = BackColor;
if (BackImage != null) {
img.sprite = BackImage;
img.type = ImageMode;
}
}
}
public override string ToString() {
return string.Format("PContainer[Name={0}]", Name);
}
}
}

434
mod/PLibUI/PDialog.cs Normal file
View File

@@ -0,0 +1,434 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A dialog root for UI components.
/// </summary>
public sealed class PDialog : IUIComponent {
/// <summary>
/// The margin around dialog buttons.
/// </summary>
public static readonly RectOffset BUTTON_MARGIN = new RectOffset(13, 13, 13, 13);
/// <summary>
/// The margin inside the dialog close button.
/// </summary>
private static readonly RectOffset CLOSE_ICON_MARGIN = new RectOffset(4, 4, 4, 4);
/// <summary>
/// The size of the dialog close button's icon.
/// </summary>
private static readonly Vector2 CLOSE_ICON_SIZE = new Vector2f(16.0f, 16.0f);
/// <summary>
/// The dialog key returned if the user closes the dialog with [ESC] or the X.
/// </summary>
public const string DIALOG_KEY_CLOSE = "close";
/// <summary>
/// Returns a suitable parent object for a dialog.
/// </summary>
/// <returns>A game object that can be used as a dialog parent depending on the game
/// stage, or null if none is available.</returns>
public static GameObject GetParentObject() {
GameObject parent = null;
var fi = FrontEndManager.Instance;
if (fi != null)
parent = fi.gameObject;
else {
// Grr unity
var gi = GameScreenManager.Instance;
if (gi != null)
parent = gi.ssOverlayCanvas;
else
PUIUtils.LogUIWarning("No dialog parent found!");
}
return parent;
}
/// <summary>
/// Rounds the size up to the nearest even integer.
/// </summary>
/// <param name="size">The current size.</param>
/// <param name="maxSize">The maximum allowed size.</param>
/// <returns>The rounded size.</returns>
private static float RoundUpSize(float size, float maxSize) {
int upOne = Mathf.CeilToInt(size);
if (upOne % 2 == 1) upOne++;
if (upOne > maxSize && maxSize > 0.0f)
upOne -= 2;
return upOne;
}
/// <summary>
/// The dialog body panel. To add custom components to the dialog, use AddChild on
/// this panel. Its direction, margin, and spacing can also be customized.
/// </summary>
public PPanel Body { get; }
/// <summary>
/// The background color of the dialog itself (including button panel).
/// </summary>
public Color DialogBackColor { get; set; }
/// <summary>
/// The dialog's maximum size. If the dialog preferred size is bigger than this size,
/// the dialog will be decreased in size to fit. If either axis is zero, the dialog
/// gets its preferred size in that axis, at least the value in Size.
/// </summary>
public Vector2 MaxSize { get; set; }
public string Name { get; }
/// <summary>
/// The dialog's parent.
/// </summary>
public GameObject Parent { get; set; }
/// <summary>
/// If a dialog with an odd width/height is displayed, all offsets will end up on a
/// half pixel offset, which may cause unusual display artifacts as Banker's Rounding
/// will round values that are supposed to be 1.0 units apart into integer values 2
/// units apart. If set, this flag will cause Build to round the dialog's size up to
/// the nearest even integer. If the dialog is already at its maximum size and is still
/// an odd integer in size, it is rounded down one instead.
/// </summary>
public bool RoundToNearestEven { get; set; }
/// <summary>
/// The dialog's minimum size. If the dialog preferred size is bigger than this size,
/// the dialog will be increased in size to fit. If either axis is zero, the dialog
/// gets its preferred size in that axis, up until the value in MaxSize.
/// </summary>
public Vector2 Size { get; set; }
/// <summary>
/// The dialog sort order which determines which other dialogs this one is on top of.
/// </summary>
public float SortKey { get; set; }
/// <summary>
/// The dialog's title.
/// </summary>
public string Title { get; set; }
/// <summary>
/// The allowable button choices for the dialog.
/// </summary>
private readonly ICollection<DialogButton> buttons;
/// <summary>
/// The events to invoke when the dialog is closed.
/// </summary>
public PUIDelegates.OnDialogClosed DialogClosed { get; set; }
public event PUIDelegates.OnRealize OnRealize;
public PDialog(string name) {
Body = new PPanel("Body") {
Alignment = TextAnchor.UpperCenter, FlexSize = Vector2.one,
Margin = new RectOffset(6, 6, 6, 6)
};
DialogBackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
buttons = new List<DialogButton>(4);
MaxSize = Vector2.zero;
Name = name ?? "Dialog";
// First try the front end manager (menu), then the in-game (game)
Parent = GetParentObject();
RoundToNearestEven = false;
Size = Vector2.zero;
SortKey = 0.0f;
Title = "Dialog";
}
/// <summary>
/// Adds a button to the dialog. The button will use a blue background with white text
/// in the default UI font, except for the last button which will be pink.
/// </summary>
/// <param name="key">The key to report if this button is selected.</param>
/// <param name="text">The button text.</param>
/// <param name="tooltip">The tooltip to display on the button (optional)</param>
/// <returns>This dialog for call chaining.</returns>
public PDialog AddButton(string key, string text, string tooltip = null) {
buttons.Add(new DialogButton(key, text, tooltip, null, null));
return this;
}
/// <summary>
/// Adds a button to the dialog.
/// </summary>
/// <param name="key">The key to report if this button is selected.</param>
/// <param name="text">The button text.</param>
/// <param name="tooltip">The tooltip to display on the button (optional)</param>
/// <param name="backColor">The background color to use for the button. If null or
/// omitted, the last button will be pink and all others will be blue.</param>
/// <param name="foreColor">The foreground color to use for the button. If null or
/// omitted, white text with the default game UI font will be used.</param>
/// <returns>This dialog for call chaining.</returns>
public PDialog AddButton(string key, string text, string tooltip = null,
ColorStyleSetting backColor = null, TextStyleSetting foreColor = null) {
buttons.Add(new DialogButton(key, text, tooltip, backColor, foreColor));
return this;
}
/// <summary>
/// Adds a handler when this dialog is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This dialog for call chaining.</returns>
public PDialog AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
if (Parent == null)
throw new InvalidOperationException("Parent for dialog may not be null");
var dialog = PUIElements.CreateUI(Parent, Name);
var dComponent = dialog.AddComponent<PDialogComp>();
// Background
dialog.AddComponent<Canvas>();
dialog.AddComponent<GraphicRaycaster>();
var bg = dialog.AddComponent<Image>();
bg.color = DialogBackColor;
bg.sprite = PUITuning.Images.BoxBorder;
bg.type = Image.Type.Sliced;
// Add each component
var layout = dialog.AddComponent<PGridLayoutGroup>();
layout.AddRow(new GridRowSpec());
layout.AddRow(new GridRowSpec(flex: 1.0f));
layout.AddRow(new GridRowSpec());
layout.AddColumn(new GridColumnSpec(flex: 1.0f));
layout.AddColumn(new GridColumnSpec());
LayoutTitle(layout, dComponent.DoButton);
layout.AddComponent(Body.Build(), new GridComponentSpec(1, 0) {
ColumnSpan = 2, Margin = new RectOffset(10, 10, 10, 10)
});
CreateUserButtons(layout, dComponent.DoButton);
// Configure body position
SetDialogSize(dialog);
// Dialog is realized
dComponent.dialog = this;
dComponent.sortKey = SortKey;
OnRealize?.Invoke(dialog);
return dialog;
}
/// <summary>
/// Creates the user buttons.
/// </summary>
/// <param name="layout">The location to add the buttons.</param>
/// <param name="onPressed">The handler to call when any button is pressed.</param>
private void CreateUserButtons(PGridLayoutGroup layout,
PUIDelegates.OnButtonPressed onPressed) {
var buttonPanel = new PPanel("Buttons") {
Alignment = TextAnchor.LowerCenter, Spacing = 7, Direction = PanelDirection.
Horizontal, Margin = new RectOffset(5, 5, 0, 10)
};
int i = 0;
// Add each user button
foreach (var button in buttons) {
string key = button.key;
var bgColor = button.backColor;
var fgColor = button.textColor ?? PUITuning.Fonts.UILightStyle;
var db = new PButton(key) {
Text = button.text, ToolTip = button.tooltip, Margin = BUTTON_MARGIN,
OnClick = onPressed, Color = bgColor, TextStyle = fgColor
};
// Last button is special and gets a pink color
if (bgColor == null) {
if (++i >= buttons.Count)
db.SetKleiPinkStyle();
else
db.SetKleiBlueStyle();
}
buttonPanel.AddChild(db);
}
layout.AddComponent(buttonPanel.Build(), new GridComponentSpec(2, 0) {
ColumnSpan = 2
});
}
/// <summary>
/// Lays out the dialog title bar and close button.
/// </summary>
/// <param name="layout">The layout manager for the dialog.</param>
/// <param name="onClose">The action to invoke when close is pressed.</param>
private void LayoutTitle(PGridLayoutGroup layout, PUIDelegates.OnButtonPressed
onClose) {
// Title text, expand to width
var title = new PLabel("Title") {
Margin = new RectOffset(3, 4, 0, 0), Text = Title, FlexSize = Vector2.one
}.SetKleiPinkColor().Build();
layout.AddComponent(title, new GridComponentSpec(0, 0) {
Margin = new RectOffset(0, -2, 0, 0)
});
// Black border on the title bar overlaps the edge, but 0 margins so should be OK
// Fixes the 2px bug handily!
var titleBG = title.AddOrGet<Image>();
titleBG.sprite = PUITuning.Images.BoxBorder;
titleBG.type = Image.Type.Sliced;
// Close button
layout.AddComponent(new PButton(DIALOG_KEY_CLOSE) {
Sprite = PUITuning.Images.Close, Margin = CLOSE_ICON_MARGIN, OnClick = onClose,
SpriteSize = CLOSE_ICON_SIZE, ToolTip = STRINGS.UI.TOOLTIPS.CLOSETOOLTIP
}.SetKleiBlueStyle().Build(), new GridComponentSpec(0, 1));
}
/// <summary>
/// Sets the final size of the dialog using its current position.
/// </summary>
/// <param name="dialog">The realized dialog with all components populated.</param>
private void SetDialogSize(GameObject dialog) {
// Calculate the final dialog size
var dialogRT = dialog.rectTransform();
LayoutRebuilder.ForceRebuildLayoutImmediate(dialogRT);
float bodyWidth = Math.Max(Size.x, LayoutUtility.GetPreferredWidth(dialogRT)),
bodyHeight = Math.Max(Size.y, LayoutUtility.GetPreferredHeight(dialogRT)),
maxX = MaxSize.x, maxY = MaxSize.y;
// Maximum size constraint
if (maxX > 0.0f)
bodyWidth = Math.Min(bodyWidth, maxX);
if (maxY > 0.0f)
bodyHeight = Math.Min(bodyHeight, maxY);
if (RoundToNearestEven) {
// Round up the size to even integers, even if currently fractional
bodyWidth = RoundUpSize(bodyWidth, maxX);
bodyHeight = RoundUpSize(bodyHeight, maxY);
}
dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, bodyWidth);
dialogRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, bodyHeight);
}
/// <summary>
/// Builds and shows this dialog.
/// </summary>
public void Show() {
if (Build().TryGetComponent(out KScreen screen))
UIDetours.ACTIVATE_KSCREEN.Invoke(screen);
}
public override string ToString() {
return string.Format("PDialog[Name={0},Title={1}]", Name, Title);
}
/// <summary>
/// Stores information about a dialog button in this dialog.
/// </summary>
private sealed class DialogButton {
/// <summary>
/// The color to use when displaying the button. If null, the default color will
/// be used.
/// </summary>
public readonly ColorStyleSetting backColor;
/// <summary>
/// The button key used to indicate that it was selected.
/// </summary>
public readonly string key;
/// <summary>
/// The text to display for the button.
/// </summary>
public readonly string text;
/// <summary>
/// The color to use when displaying the button text. If null, the default color
/// will be used.
/// </summary>
public readonly TextStyleSetting textColor;
/// <summary>
/// The tooltip for this button.
/// </summary>
public readonly string tooltip;
internal DialogButton(string key, string text, string tooltip,
ColorStyleSetting backColor, TextStyleSetting foreColor) {
this.backColor = backColor;
this.key = key;
this.text = text;
this.tooltip = tooltip;
textColor = foreColor;
}
public override string ToString() {
return string.Format("DialogButton[key={0:D},text={1:D}]", key, text);
}
}
/// <summary>
/// The Klei component which backs the dialog.
/// </summary>
private sealed class PDialogComp : KScreen {
/// <summary>
/// The events to invoke when the dialog is closed.
/// </summary>
internal PDialog dialog;
/// <summary>
/// The key selected by the user.
/// </summary>
internal string key;
/// <summary>
/// The sort order of this dialog.
/// </summary>
internal float sortKey;
internal PDialogComp() {
key = DIALOG_KEY_CLOSE;
sortKey = 0.0f;
}
/// <summary>
/// A delegate which closes the dialog on prompt.
/// </summary>
/// <param name="source">The button source.</param>
internal void DoButton(GameObject source) {
key = source.name;
UIDetours.DEACTIVATE_KSCREEN.Invoke(this);
}
public override float GetSortKey() {
return sortKey;
}
protected override void OnDeactivate() {
if (dialog != null)
// Klei destroys the dialog GameObject for us
dialog.DialogClosed?.Invoke(key);
base.OnDeactivate();
dialog = null;
}
public override void OnKeyDown(KButtonEvent e) {
if (e.TryConsume(Action.Escape))
UIDetours.DEACTIVATE_KSCREEN.Invoke(this);
else
base.OnKeyDown(e);
}
}
}
}

144
mod/PLibUI/PGridPanel.cs Normal file
View File

@@ -0,0 +1,144 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections.Generic;
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// A panel which lays out its components using grid-type constraints.
/// </summary>
public class PGridPanel : PContainer, IDynamicSizable {
/// <summary>
/// The number of columns currently defined.
/// </summary>
public int Columns => columns.Count;
public bool DynamicSize { get; set; }
/// <summary>
/// The number of rows currently defined.
/// </summary>
public int Rows => rows.Count;
/// <summary>
/// The children of this panel.
/// </summary>
private readonly ICollection<GridComponent<IUIComponent>> children;
/// <summary>
/// The columns in this panel.
/// </summary>
private readonly IList<GridColumnSpec> columns;
/// <summary>
/// The rows in this panel.
/// </summary>
private readonly IList<GridRowSpec> rows;
public PGridPanel() : this(null) { }
public PGridPanel(string name) : base(name ?? "GridPanel") {
children = new List<GridComponent<IUIComponent>>(16);
columns = new List<GridColumnSpec>(16);
rows = new List<GridRowSpec>(16);
DynamicSize = true;
Margin = null;
}
/// <summary>
/// Adds a child to this panel.
/// </summary>
/// <param name="child">The child to add.</param>
/// <param name="spec">The location where the child will be placed.</param>
/// <returns>This panel for call chaining.</returns>
public PGridPanel AddChild(IUIComponent child, GridComponentSpec spec) {
if (child == null)
throw new ArgumentNullException(nameof(child));
if (spec == null)
throw new ArgumentNullException(nameof(spec));
children.Add(new GridComponent<IUIComponent>(spec, child));
return this;
}
/// <summary>
/// Adds a column to this panel.
/// </summary>
/// <param name="column">The specification for that column.</param>
/// <returns>This panel for call chaining.</returns>
public PGridPanel AddColumn(GridColumnSpec column) {
if (column == null)
throw new ArgumentNullException(nameof(column));
columns.Add(column);
return this;
}
/// <summary>
/// Adds a handler when this panel is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This panel for call chaining.</returns>
public PGridPanel AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
/// <summary>
/// Adds a row to this panel.
/// </summary>
/// <param name="row">The specification for that row.</param>
/// <returns>This panel for call chaining.</returns>
public PGridPanel AddRow(GridRowSpec row) {
if (row == null)
throw new ArgumentNullException(nameof(row));
rows.Add(row);
return this;
}
public override GameObject Build() {
if (Columns < 1)
throw new InvalidOperationException("At least one column must be defined");
if (Rows < 1)
throw new InvalidOperationException("At least one row must be defined");
var panel = PUIElements.CreateUI(null, Name);
SetImage(panel);
// Add layout component
var layout = panel.AddComponent<PGridLayoutGroup>();
layout.Margin = Margin;
foreach (var column in columns)
layout.AddColumn(column);
foreach (var row in rows)
layout.AddRow(row);
// Add children
foreach (var child in children)
layout.AddComponent(child.Item.Build(), child);
if (!DynamicSize)
layout.LockLayout();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
InvokeRealize(panel);
return panel;
}
public override string ToString() {
return string.Format("PGridPanel[Name={0},Rows={1:D},Columns={2:D}]", Name, Rows,
Columns);
}
}
}

93
mod/PLibUI/PLabel.cs Normal file
View File

@@ -0,0 +1,93 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI label factory class.
/// </summary>
public class PLabel : PTextComponent {
/// <summary>
/// The label's background color.
/// </summary>
public Color BackColor { get; set; }
public PLabel() : this(null) { }
public PLabel(string name) : base(name ?? "Label") {
BackColor = PUITuning.Colors.Transparent;
}
/// <summary>
/// Adds a handler when this label is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This label for call chaining.</returns>
public PLabel AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public override GameObject Build() {
var label = PUIElements.CreateUI(null, Name);
GameObject sprite = null, text = null;
// Background
if (BackColor.a > 0)
label.AddComponent<Image>().color = BackColor;
// Add foreground image
if (Sprite != null)
sprite = ImageChildHelper(label, this).gameObject;
// Add text
if (!string.IsNullOrEmpty(Text))
text = TextChildHelper(label, TextStyle ?? PUITuning.Fonts.UILightStyle,
Text).gameObject;
// Add tooltip
PUIElements.SetToolTip(label, ToolTip).SetActive(true);
// Arrange the icon and text
var layout = label.AddComponent<RelativeLayoutGroup>();
layout.Margin = Margin;
ArrangeComponent(layout, WrapTextAndSprite(text, sprite), TextAlignment);
if (!DynamicSize) layout.LockLayout();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
DestroyLayoutIfPossible(label);
InvokeRealize(label);
return label;
}
/// <summary>
/// Sets the background color to the default Klei dialog blue.
/// </summary>
/// <returns>This label for call chaining.</returns>
public PLabel SetKleiBlueColor() {
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the background color to the Klei dialog header pink.
/// </summary>
/// <returns>This label for call chaining.</returns>
public PLabel SetKleiPinkColor() {
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
return this;
}
}
}

20
mod/PLibUI/PLibUI.csproj Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>PLib User Interface</Title>
<AssemblyTitle>PLib.UI</AssemblyTitle>
<Version>4.11.0.0</Version>
<UsesPLib>false</UsesPLib>
<RootNamespace>PeterHan.PLib.UI</RootNamespace>
<AssemblyVersion>4.11.0.0</AssemblyVersion>
<DistributeMod>false</DistributeMod>
<PLibCore>true</PLibCore>
<Platforms>Vanilla;Mergedown</Platforms>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\$(Platform)\Release\PLibUI.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../PLibCore/PLibCore.csproj" />
</ItemGroup>
</Project>

168
mod/PLibUI/PPanel.cs Normal file
View File

@@ -0,0 +1,168 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using System;
using System.Collections.Generic;
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI panel factory which can arrange its children horizontally or vertically.
/// </summary>
public class PPanel : PContainer, IDynamicSizable {
/// <summary>
/// The alignment position to use for child elements if they are smaller than the
/// required size.
/// </summary>
public TextAnchor Alignment { get; set; }
/// <summary>
/// The direction in which components will be laid out.
/// </summary>
public PanelDirection Direction { get; set; }
public bool DynamicSize { get; set; }
/// <summary>
/// The spacing between components in pixels.
/// </summary>
public int Spacing { get; set; }
/// <summary>
/// The children of this panel.
/// </summary>
protected readonly ICollection<IUIComponent> children;
public PPanel() : this(null) { }
public PPanel(string name) : base(name ?? "Panel") {
Alignment = TextAnchor.MiddleCenter;
children = new List<IUIComponent>();
Direction = PanelDirection.Vertical;
DynamicSize = true;
Spacing = 0;
}
/// <summary>
/// Adds a child to this panel.
/// </summary>
/// <param name="child">The child to add.</param>
/// <returns>This panel for call chaining.</returns>
public PPanel AddChild(IUIComponent child) {
if (child == null)
throw new ArgumentNullException(nameof(child));
children.Add(child);
return this;
}
/// <summary>
/// Adds a handler when this panel is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This panel for call chaining.</returns>
public PPanel AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public override GameObject Build() {
return Build(default, DynamicSize);
}
/// <summary>
/// Builds this panel.
/// </summary>
/// <param name="size">The fixed size to use if dynamic is false.</param>
/// <param name="dynamic">Whether to use dynamic sizing.</param>
/// <returns>The realized panel.</returns>
private GameObject Build(Vector2 size, bool dynamic) {
var panel = PUIElements.CreateUI(null, Name);
SetImage(panel);
// Add children
foreach (var child in children) {
var obj = child.Build();
obj.SetParent(panel);
PUIElements.SetAnchors(obj, PUIAnchoring.Stretch, PUIAnchoring.Stretch);
}
var lg = panel.AddComponent<BoxLayoutGroup>();
lg.Params = new BoxLayoutParams() {
Direction = Direction, Alignment = Alignment, Spacing = Spacing,
Margin = Margin
};
if (!dynamic) {
lg.LockLayout();
panel.SetMinUISize(size);
}
lg.flexibleWidth = FlexSize.x;
lg.flexibleHeight = FlexSize.y;
InvokeRealize(panel);
return panel;
}
/// <summary>
/// Builds this panel with a given default size.
/// </summary>
/// <param name="size">The fixed size to use.</param>
/// <returns>The realized panel.</returns>
public GameObject BuildWithFixedSize(Vector2 size) {
return Build(size, false);
}
/// <summary>
/// Removes a child from this panel.
/// </summary>
/// <param name="child">The child to remove.</param>
/// <returns>This panel for call chaining.</returns>
public PPanel RemoveChild(IUIComponent child) {
if (child == null)
throw new ArgumentNullException(nameof(child));
children.Remove(child);
return this;
}
/// <summary>
/// Sets the background color to the default Klei dialog blue.
/// </summary>
/// <returns>This panel for call chaining.</returns>
public PPanel SetKleiBlueColor() {
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the background color to the Klei dialog header pink.
/// </summary>
/// <returns>This panel for call chaining.</returns>
public PPanel SetKleiPinkColor() {
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
return this;
}
public override string ToString() {
return string.Format("PPanel[Name={0},Direction={1}]", Name, Direction);
}
}
/// <summary>
/// The direction in which PPanel lays out components.
/// </summary>
public enum PanelDirection {
Horizontal, Vertical
}
}

View File

@@ -0,0 +1,338 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using PeterHan.PLib.UI.Layouts;
using System;
using System.Collections.Generic;
using UnityEngine;
using RelativeConfig = PeterHan.PLib.UI.Layouts.RelativeLayoutParamsBase<PeterHan.PLib.UI.
IUIComponent>;
namespace PeterHan.PLib.UI {
/// <summary>
/// A panel which lays out its components using relative constraints.
///
/// This layout manager is the fastest of all panels when laid out, especially since it
/// can function properly when frozen even on dynamically sized items. However, it is also
/// the most difficult to set up and cannot handle all layouts.
/// </summary>
public class PRelativePanel : PContainer, IDynamicSizable {
/// <summary>
/// Constraints for each object are stored here.
/// </summary>
private readonly IDictionary<IUIComponent, RelativeConfig> constraints;
public bool DynamicSize { get; set; }
public PRelativePanel() : this(null) {
DynamicSize = true;
}
public PRelativePanel(string name) : base(name ?? "RelativePanel") {
constraints = new Dictionary<IUIComponent, RelativeConfig>(16);
Margin = null;
}
/// <summary>
/// Adds a child to this panel. Children must be added to the panel before they are
/// referenced in a constraint.
/// </summary>
/// <param name="child">The child to add.</param>
/// <returns>This panel for call chaining.</returns>
public PRelativePanel AddChild(IUIComponent child) {
if (child == null)
throw new ArgumentNullException(nameof(child));
if (!constraints.ContainsKey(child))
constraints.Add(child, new RelativeConfig());
return this;
}
/// <summary>
/// Adds a handler when this panel is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This panel for call chaining.</returns>
public PRelativePanel AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
/// <summary>
/// Anchors the component's pivot in the X axis to the specified anchor position.
/// The component will be laid out at its preferred (or overridden) width with its
/// pivot locked to the specified relative fraction of the parent component's width.
///
/// Any other existing left or right edge constraints will be overwritten. This method
/// is equivalent to setting both the left and right edges to the same fraction.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="anchor">The fraction to which to align the pivot, with 0.0f
/// being the left and 1.0f being the right.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel AnchorXAxis(IUIComponent item, float anchor = 0.5f) {
SetLeftEdge(item, fraction: anchor);
return SetRightEdge(item, fraction: anchor);
}
/// <summary>
/// Anchors the component's pivot in the Y axis to the specified anchor position.
/// The component will be laid out at its preferred (or overridden) height with its
/// pivot locked to the specified relative fraction of the parent component's height.
///
/// Any other existing top or bottom edge constraints will be overwritten. This method
/// is equivalent to setting both the top and bottom edges to the same fraction.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="anchor">The fraction to which to align the pivot, with 0.0f
/// being the bottom and 1.0f being the top.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel AnchorYAxis(IUIComponent item, float anchor = 0.5f) {
SetTopEdge(item, fraction: anchor);
return SetBottomEdge(item, fraction: anchor);
}
public override GameObject Build() {
var panel = PUIElements.CreateUI(null, Name);
var mapping = DictionaryPool<IUIComponent, GameObject, PRelativePanel>.Allocate();
SetImage(panel);
// Realize each component and add them to the panel
foreach (var pair in constraints) {
var component = pair.Key;
var realized = component.Build();
realized.SetParent(panel);
// We were already guaranteed that there were no duplicate keys
mapping[component] = realized;
}
// Add layout component
var layout = panel.AddComponent<RelativeLayoutGroup>();
layout.Margin = Margin;
foreach (var pair in constraints) {
var realized = mapping[pair.Key];
var rawParams = pair.Value;
var newParams = new RelativeLayoutParams();
// Copy all of the settings
Resolve(newParams.TopEdge, rawParams.TopEdge, mapping);
Resolve(newParams.BottomEdge, rawParams.BottomEdge, mapping);
Resolve(newParams.LeftEdge, rawParams.LeftEdge, mapping);
Resolve(newParams.RightEdge, rawParams.RightEdge, mapping);
newParams.OverrideSize = rawParams.OverrideSize;
newParams.Insets = rawParams.Insets;
layout.SetRaw(realized, newParams);
}
if (!DynamicSize)
layout.LockLayout();
mapping.Recycle();
// Set flex size
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
InvokeRealize(panel);
return panel;
}
/// <summary>
/// Retrieves the constraints for a component, or throws an exception if the component
/// has not yet been added.
/// </summary>
/// <param name="item">The unrealized component to look up.</param>
/// <returns>The constraints for that component.</returns>
/// <exception cref="ArgumentException">If the component has not yet been added to the panel.</exception>
private RelativeConfig GetOrThrow(IUIComponent item) {
if (item == null)
throw new ArgumentNullException(nameof(item));
if (!constraints.TryGetValue(item, out RelativeConfig value))
throw new ArgumentException("Components must be added to the panel before using them in a constraint");
return value;
}
/// <summary>
/// Overrides the preferred size of a component. If set, instead of looking at layout
/// sizes of the component, the specified size will be used instead.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="size">The size to apply. Only dimensions greater than zero will be used.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel OverrideSize(IUIComponent item, Vector2 size) {
if (item != null)
GetOrThrow(item).OverrideSize = size;
return this;
}
/// <summary>
/// Converts the edge settings configured in this component to settings for the
/// relative panel.
/// </summary>
/// <param name="dest">The location where the converted settings will be stored.</param>
/// <param name="status">The original component edge configuration.</param>
/// <param name="mapping">The mapping from PLib UI components to Unity objects.</param>
private void Resolve(RelativeLayoutParams.EdgeStatus dest, RelativeConfig.EdgeStatus
status, IDictionary<IUIComponent, GameObject> mapping) {
var c = status.FromComponent;
dest.FromAnchor = status.FromAnchor;
if (c != null)
dest.FromComponent = mapping[c];
dest.Constraint = status.Constraint;
dest.Offset = status.Offset;
}
/// <summary>
/// Sets the bottom edge of a game object. If the fraction is supplied, the component
/// will be laid out with the bottom edge anchored to that fraction of the parent's
/// height. If a component is specified and no fraction is specified, the component
/// will be anchored with its bottom edge above the top edge of that component.
/// If neither is specified, all bottom edge constraints will be removed.
///
/// Any other existing bottom edge constraint will be overwritten.
///
/// Remember that +Y is in the upwards direction.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="fraction">The fraction to which to align the bottom edge, with 0.0f
/// being the bottom and 1.0f being the top.</param>
/// <param name="above">The game object which this component must be above.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel SetBottomEdge(IUIComponent item, float fraction = -1.0f,
IUIComponent above = null) {
if (item != null) {
if (above == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(GetOrThrow(item).BottomEdge, fraction, above);
}
return this;
}
/// <summary>
/// Sets a component's edge constraint.
/// </summary>
/// <param name="edge">The edge to set.</param>
/// <param name="fraction">The fraction of the parent to anchor.</param>
/// <param name="child">The other component to anchor.</param>
private void SetEdge(RelativeConfig.EdgeStatus edge, float fraction,
IUIComponent child) {
if (fraction >= 0.0f && fraction <= 1.0f) {
edge.Constraint = RelativeConstraintType.ToAnchor;
edge.FromAnchor = fraction;
edge.FromComponent = null;
} else if (child != null) {
edge.Constraint = RelativeConstraintType.ToComponent;
edge.FromComponent = child;
} else {
edge.Constraint = RelativeConstraintType.Unconstrained;
edge.FromComponent = null;
}
}
/// <summary>
/// Sets the left edge of a game object. If the fraction is supplied, the component
/// will be laid out with the left edge anchored to that fraction of the parent's
/// width. If a component is specified and no fraction is specified, the component
/// will be anchored with its left edge to the right of that component.
/// If neither is specified, all left edge constraints will be removed.
///
/// Any other existing left edge constraint will be overwritten.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="fraction">The fraction to which to align the left edge, with 0.0f
/// being the left and 1.0f being the right.</param>
/// <param name="toLeft">The game object which this component must be to the right of.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel SetLeftEdge(IUIComponent item, float fraction = -1.0f,
IUIComponent toRight = null) {
if (item != null) {
if (toRight == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(GetOrThrow(item).LeftEdge, fraction, toRight);
}
return this;
}
/// <summary>
/// Sets the insets of a component from its anchor points. A positive number insets the
/// component away from the edge, whereas a negative number out-sets the component
/// across the edge.
///
/// All components default to no insets.
///
/// Any reference to a component's edge using other constraints always refers to its
/// edge <b>before</b> insets are applied.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="insets">The insets to apply. If null, the insets will be set to zero.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel SetMargin(IUIComponent item, RectOffset insets) {
if (item != null)
GetOrThrow(item).Insets = insets;
return this;
}
/// <summary>
/// Sets the right edge of a game object. If the fraction is supplied, the component
/// will be laid out with the right edge anchored to that fraction of the parent's
/// width. If a component is specified and no fraction is specified, the component
/// will be anchored with its right edge to the left of that component.
/// If neither is specified, all right edge constraints will be removed.
///
/// Any other existing right edge constraint will be overwritten.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="fraction">The fraction to which to align the right edge, with 0.0f
/// being the left and 1.0f being the right.</param>
/// <param name="toLeft">The game object which this component must be to the left of.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel SetRightEdge(IUIComponent item, float fraction = -1.0f,
IUIComponent toLeft = null) {
if (item != null) {
if (toLeft == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(GetOrThrow(item).RightEdge, fraction, toLeft);
}
return this;
}
/// <summary>
/// Sets the top edge of a game object. If the fraction is supplied, the component
/// will be laid out with the top edge anchored to that fraction of the parent's
/// height. If a component is specified and no fraction is specified, the component
/// will be anchored with its top edge above the bottom edge of that component.
/// If neither is specified, all top edge constraints will be removed.
///
/// Any other existing top edge constraint will be overwritten.
///
/// Remember that +Y is in the upwards direction.
/// </summary>
/// <param name="item">The component to adjust.</param>
/// <param name="fraction">The fraction to which to align the top edge, with 0.0f
/// being the bottom and 1.0f being the top.</param>
/// <param name="below">The game object which this component must be below.</param>
/// <returns>This object, for call chaining.</returns>
public PRelativePanel SetTopEdge(IUIComponent item, float fraction = -1.0f,
IUIComponent below = null) {
if (item != null) {
if (below == item)
throw new ArgumentException("Component cannot refer directly to itself");
SetEdge(GetOrThrow(item).TopEdge, fraction, below);
}
return this;
}
public override string ToString() {
return string.Format("PRelativePanel[Name={0}]", Name);
}
}
}

376
mod/PLibUI/PScrollPane.cs Normal file
View File

@@ -0,0 +1,376 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using PeterHan.PLib.UI.Layouts;
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A factory for scrollable panes.
/// </summary>
public sealed class PScrollPane : IUIComponent {
/// <summary>
/// The track size of scrollbars is based on the sprite.
/// </summary>
private const float DEFAULT_TRACK_SIZE = 16.0f;
/// <summary>
/// Whether the horizontal scrollbar is always visible.
/// </summary>
public bool AlwaysShowHorizontal { get; set; }
/// <summary>
/// Whether the vertical scrollbar is always visible.
/// </summary>
public bool AlwaysShowVertical { get; set; }
/// <summary>
/// The background color of this scroll pane.
/// </summary>
public Color BackColor { get; set; }
/// <summary>
/// The child of this scroll pane.
/// </summary>
public IUIComponent Child { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
public string Name { get; }
/// <summary>
/// Whether horizontal scrolling is allowed.
/// </summary>
public bool ScrollHorizontal { get; set; }
/// <summary>
/// Whether vertical scrolling is allowed.
/// </summary>
public bool ScrollVertical { get; set; }
/// <summary>
/// The size of the scrollbar track.
/// </summary>
public float TrackSize { get; set; }
public event PUIDelegates.OnRealize OnRealize;
public PScrollPane() : this(null) { }
public PScrollPane(string name) {
AlwaysShowHorizontal = AlwaysShowVertical = true;
BackColor = PUITuning.Colors.Transparent;
Child = null;
FlexSize = Vector2.zero;
Name = name ?? "Scroll";
ScrollHorizontal = false;
ScrollVertical = false;
TrackSize = DEFAULT_TRACK_SIZE;
}
/// <summary>
/// Adds a handler when this scroll pane is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This scroll pane for call chaining.</returns>
public PScrollPane AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
if (Child == null)
throw new InvalidOperationException("No child component");
var pane = BuildScrollPane(null, Child.Build());
OnRealize?.Invoke(pane);
return pane;
}
/// <summary>
/// Builds the actual scroll pane object.
/// </summary>
/// <param name="parent">The parent of this scroll pane.</param>
/// <param name="child">The child element of this scroll pane.</param>
/// <returns>The realized scroll pane.</returns>
internal GameObject BuildScrollPane(GameObject parent, GameObject child) {
var pane = PUIElements.CreateUI(parent, Name);
if (BackColor.a > 0.0f)
pane.AddComponent<Image>().color = BackColor;
pane.SetActive(false);
// Scroll pane itself
var scroll = pane.AddComponent<KScrollRect>();
scroll.horizontal = ScrollHorizontal;
scroll.vertical = ScrollVertical;
// Viewport
var viewport = PUIElements.CreateUI(pane, "Viewport");
viewport.rectTransform().pivot = Vector2.up;
viewport.AddComponent<RectMask2D>().enabled = true;
viewport.AddComponent<ViewportLayoutGroup>();
scroll.viewport = viewport.rectTransform();
// Give the Child a separate Canvas to reduce layout rebuilds
child.AddOrGet<Canvas>().pixelPerfect = false;
child.AddOrGet<GraphicRaycaster>();
PUIElements.SetAnchors(child.SetParent(viewport), PUIAnchoring.Beginning,
PUIAnchoring.End);
scroll.content = child.rectTransform();
// Vertical scrollbar
if (ScrollVertical) {
scroll.verticalScrollbar = CreateScrollVert(pane);
scroll.verticalScrollbarVisibility = AlwaysShowVertical ? ScrollRect.
ScrollbarVisibility.Permanent : ScrollRect.ScrollbarVisibility.
AutoHideAndExpandViewport;
}
if (ScrollHorizontal) {
scroll.horizontalScrollbar = CreateScrollHoriz(pane);
scroll.horizontalScrollbarVisibility = AlwaysShowHorizontal ? ScrollRect.
ScrollbarVisibility.Permanent : ScrollRect.ScrollbarVisibility.
AutoHideAndExpandViewport;
}
pane.SetActive(true);
// Custom layout to pass child sizes to the scroll pane
var layout = pane.AddComponent<PScrollPaneLayout>();
layout.flexibleHeight = FlexSize.y;
layout.flexibleWidth = FlexSize.x;
return pane;
}
/// <summary>
/// Creates a horizontal scroll bar.
/// </summary>
/// <param name="parent">The parent component.</param>
/// <returns>The scroll bar component.</returns>
private Scrollbar CreateScrollHoriz(GameObject parent) {
// Outer scrollbar
var track = PUIElements.CreateUI(parent, "Scrollbar H", true, PUIAnchoring.Stretch,
PUIAnchoring.Beginning);
var bg = track.AddComponent<Image>();
bg.sprite = PUITuning.Images.ScrollBorderHorizontal;
bg.type = Image.Type.Sliced;
// Scroll track
var sb = track.AddComponent<Scrollbar>();
sb.interactable = true;
sb.transition = Selectable.Transition.ColorTint;
sb.colors = PUITuning.Colors.ScrollbarColors;
sb.SetDirection(Scrollbar.Direction.RightToLeft, true);
// Handle
var handle = PUIElements.CreateUI(track, "Handle", true, PUIAnchoring.Stretch,
PUIAnchoring.End);
PUIElements.SetAnchorOffsets(handle, 1.0f, 1.0f, 1.0f, 1.0f);
sb.handleRect = handle.rectTransform();
var hImg = handle.AddComponent<Image>();
hImg.sprite = PUITuning.Images.ScrollHandleHorizontal;
hImg.type = Image.Type.Sliced;
sb.targetGraphic = hImg;
track.SetActive(true);
PUIElements.SetAnchorOffsets(track, 2.0f, 2.0f, -TrackSize, 0.0f);
return sb;
}
/// <summary>
/// Creates a vertical scroll bar.
/// </summary>
/// <param name="parent">The parent component.</param>
/// <returns>The scroll bar component.</returns>
private Scrollbar CreateScrollVert(GameObject parent) {
// Outer scrollbar
var track = PUIElements.CreateUI(parent, "Scrollbar V", true, PUIAnchoring.End,
PUIAnchoring.Stretch);
var bg = track.AddComponent<Image>();
bg.sprite = PUITuning.Images.ScrollBorderVertical;
bg.type = Image.Type.Sliced;
// Scroll track
var sb = track.AddComponent<Scrollbar>();
sb.interactable = true;
sb.transition = Selectable.Transition.ColorTint;
sb.colors = PUITuning.Colors.ScrollbarColors;
sb.SetDirection(Scrollbar.Direction.BottomToTop, true);
// Handle
var handle = PUIElements.CreateUI(track, "Handle", true, PUIAnchoring.Stretch,
PUIAnchoring.Beginning);
PUIElements.SetAnchorOffsets(handle, 1.0f, 1.0f, 1.0f, 1.0f);
sb.handleRect = handle.rectTransform();
var hImg = handle.AddComponent<Image>();
hImg.sprite = PUITuning.Images.ScrollHandleVertical;
hImg.type = Image.Type.Sliced;
sb.targetGraphic = hImg;
track.SetActive(true);
PUIElements.SetAnchorOffsets(track, -TrackSize, 0.0f, 2.0f, 2.0f);
return sb;
}
/// <summary>
/// Sets the background color to the default Klei dialog blue.
/// </summary>
/// <returns>This scroll pane for call chaining.</returns>
public PScrollPane SetKleiBlueColor() {
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the background color to the Klei dialog header pink.
/// </summary>
/// <returns>This scroll pane for call chaining.</returns>
public PScrollPane SetKleiPinkColor() {
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
return this;
}
public override string ToString() {
return string.Format("PScrollPane[Name={0},Child={1}]", Name, Child);
}
/// <summary>
/// Handles layout for scroll panes. Not freezable.
/// </summary>
private sealed class PScrollPaneLayout : AbstractLayoutGroup {
/// <summary>
/// Caches elements when calculating layout to improve performance.
/// </summary>
private Component[] calcElements;
/// <summary>
/// The calculated horizontal size of the child element.
/// </summary>
private LayoutSizes childHorizontal;
/// <summary>
/// The calculated vertical size of the child element.
/// </summary>
private LayoutSizes childVertical;
/// <summary>
/// The child object inside the scroll rect.
/// </summary>
private GameObject child;
/// <summary>
/// Caches elements when setting layout to improve performance.
/// </summary>
private ILayoutController[] setElements;
/// <summary>
/// The viewport which clips the child rectangle.
/// </summary>
private GameObject viewport;
internal PScrollPaneLayout() {
minHeight = minWidth = 0.0f;
child = viewport = null;
}
public override void CalculateLayoutInputHorizontal() {
if (child == null)
UpdateComponents();
if (child != null) {
calcElements = child.GetComponents<Component>();
// Lay out children
childHorizontal = PUIUtils.CalcSizes(child, PanelDirection.Horizontal,
calcElements);
if (childHorizontal.ignore)
throw new InvalidOperationException("ScrollPane child ignores layout!");
preferredWidth = childHorizontal.preferred;
}
}
public override void CalculateLayoutInputVertical() {
if (child == null)
UpdateComponents();
if (child != null && calcElements != null) {
// Lay out children
childVertical = PUIUtils.CalcSizes(child, PanelDirection.Vertical,
calcElements);
preferredHeight = childVertical.preferred;
calcElements = null;
}
}
protected override void OnEnable() {
base.OnEnable();
UpdateComponents();
}
public override void SetLayoutHorizontal() {
if (viewport != null && child != null) {
// Observe the flex width
float prefWidth = childHorizontal.preferred;
float actualWidth = viewport.rectTransform().rect.width;
if (prefWidth < actualWidth && childHorizontal.flexible > 0.0f)
prefWidth = actualWidth;
// Resize child
setElements = child.GetComponents<ILayoutController>();
child.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis.
Horizontal, prefWidth);
// ScrollRect does not rebuild the child's layout
foreach (var component in setElements)
component.SetLayoutHorizontal();
}
}
public override void SetLayoutVertical() {
if (viewport != null && child != null && setElements != null) {
// Observe the flex height
float prefHeight = childVertical.preferred;
float actualHeight = viewport.rectTransform().rect.height;
if (prefHeight < actualHeight && childVertical.flexible > 0.0f)
prefHeight = actualHeight;
// Resize child
child.rectTransform().SetSizeWithCurrentAnchors(RectTransform.Axis.
Vertical, prefHeight);
// ScrollRect does not rebuild the child's layout
foreach (var component in setElements)
component.SetLayoutVertical();
setElements = null;
}
}
/// <summary>
/// Caches the child component for performance reasons at runtime.
/// </summary>
private void UpdateComponents() {
var obj = gameObject;
if (obj != null && obj.TryGetComponent(out ScrollRect sr)) {
child = sr.content?.gameObject;
viewport = sr.viewport?.gameObject;
} else
child = viewport = null;
}
}
/// <summary>
/// A layout group object that does nothing. While it seems completely pointless,
/// it allows LayoutRebuilder to pass by the viewport on Scroll Rects on its way up
/// the tree, thus ensuring that the scroll rect gets rebuilt.
///
/// On the way back down, this component gets skipped over by PScrollPaneLayout to
/// save on processing, and the child layout is built directly.
/// </summary>
private sealed class ViewportLayoutGroup : UIBehaviour, ILayoutGroup {
public void SetLayoutHorizontal() { }
public void SetLayoutVertical() { }
}
}
}

268
mod/PLibUI/PSlider.cs Normal file
View File

@@ -0,0 +1,268 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using System;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI slider factory class with one handle. Does not include a text field to set
/// the value.
/// </summary>
public class PSliderSingle : IUIComponent {
/// <summary>
/// Sets the current value of a realized slider.
/// </summary>
/// <param name="realized">The realized slider.</param>
/// <param name="value">The value to set.</param>
public static void SetCurrentValue(GameObject realized, float value) {
if (realized != null && realized.TryGetComponent(out KSlider slider) && !value.
IsNaNOrInfinity())
slider.value = value.InRange(slider.minValue, slider.maxValue);
}
/// <summary>
/// If true, the default Klei track and fill will be skipped; only the handle will be
/// shown.
/// </summary>
public bool CustomTrack { get; set; }
/// <summary>
/// The direction of the slider. The slider goes from minimum to maximum value in the
/// direction indicated, i.e. LeftToRight is minimum left, maximum right.
/// </summary>
public Slider.Direction Direction { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The slider's handle color.
/// </summary>
public ColorStyleSetting HandleColor { get; set; }
/// <summary>
/// The size of the slider handle.
/// </summary>
public float HandleSize { get; set; }
/// <summary>
/// The initial slider value.
/// </summary>
public float InitialValue { get; set; }
/// <summary>
/// true to make the slider snap to integers, or false to allow any representable
/// floating point number in the range.
/// </summary>
public bool IntegersOnly { get; set; }
/// <summary>
/// The maximum value that can be set by this slider. The slider is a linear scale, but
/// can be post-scaled by the user to nonlinear if necessary.
/// </summary>
public float MaxValue { get; set; }
/// <summary>
/// The minimum value that can be set by this slider.
/// </summary>
public float MinValue { get; set; }
public string Name { get; }
/// <summary>
/// The action to trigger during slider dragging.
/// </summary>
public PUIDelegates.OnSliderDrag OnDrag { get; set; }
/// <summary>
/// The preferred length of the scrollbar. If vertical, this is the height, otherwise
/// it is the width.
/// </summary>
public float PreferredLength { get; set; }
/// <summary>
/// The action to trigger after the slider is changed. It is passed the realized source
/// object and new value.
/// </summary>
public PUIDelegates.OnSliderChanged OnValueChanged { get; set; }
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The tool tip text. If {0} is present, it will be formatted with the slider's
/// current value.
/// </summary>
public string ToolTip { get; set; }
/// <summary>
/// The size of the slider track.
/// </summary>
public float TrackSize { get; set; }
public PSliderSingle() : this("SliderSingle") { }
public PSliderSingle(string name) {
CustomTrack = false;
Direction = Slider.Direction.LeftToRight;
HandleColor = PUITuning.Colors.ButtonPinkStyle;
HandleSize = 16.0f;
InitialValue = 0.5f;
IntegersOnly = false;
MaxValue = 1.0f;
MinValue = 0.0f;
Name = name;
PreferredLength = 100.0f;
TrackSize = 12.0f;
}
/// <summary>
/// Adds a handler when this slider is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This slider for call chaining.</returns>
public PSliderSingle AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
// Bounds must be valid
if (MaxValue.IsNaNOrInfinity())
throw new ArgumentException(nameof(MaxValue));
if (MinValue.IsNaNOrInfinity())
throw new ArgumentException(nameof(MinValue));
// max > min
if (MaxValue <= MinValue)
throw new ArgumentOutOfRangeException(nameof(MaxValue));
// Initial value must be in range
var slider = PUIElements.CreateUI(null, Name);
bool isVertical = Direction == Slider.Direction.BottomToTop || Direction ==
Slider.Direction.TopToBottom;
var trueColor = HandleColor ?? PUITuning.Colors.ButtonBlueStyle;
slider.SetActive(false);
// Track (visual)
if (!CustomTrack) {
var trackImg = slider.AddComponent<Image>();
trackImg.sprite = isVertical ? PUITuning.Images.ScrollBorderVertical :
PUITuning.Images.ScrollBorderHorizontal;
trackImg.type = Image.Type.Sliced;
}
// Fill
var fill = PUIElements.CreateUI(slider, "Fill", true);
if (!CustomTrack) {
var fillImg = fill.AddComponent<Image>();
fillImg.sprite = isVertical ? PUITuning.Images.ScrollHandleVertical :
PUITuning.Images.ScrollHandleHorizontal;
fillImg.color = trueColor.inactiveColor;
fillImg.type = Image.Type.Sliced;
}
PUIElements.SetAnchorOffsets(fill, 1.0f, 1.0f, 1.0f, 1.0f);
// Slider component itself
var ks = slider.AddComponent<KSlider>();
ks.maxValue = MaxValue;
ks.minValue = MinValue;
ks.value = InitialValue.IsNaNOrInfinity() ? MinValue : InitialValue.InRange(
MinValue, MaxValue);
ks.wholeNumbers = IntegersOnly;
ks.handleRect = CreateHandle(slider).rectTransform();
ks.fillRect = fill.rectTransform();
ks.SetDirection(Direction, true);
if (OnValueChanged != null)
ks.onValueChanged.AddListener((value) => OnValueChanged(slider, value));
if (OnDrag != null)
ks.onDrag += () => OnDrag(slider, ks.value);
// Manually add tooltip with slider link
string tt = ToolTip;
if (!string.IsNullOrEmpty(tt)) {
var toolTip = slider.AddComponent<ToolTip>();
toolTip.OnToolTip = () => string.Format(tt, ks.value);
// Tooltip can be dynamically updated
toolTip.refreshWhileHovering = true;
}
slider.SetActive(true);
// Static layout!
slider.SetMinUISize(isVertical ? new Vector2(TrackSize, PreferredLength) :
new Vector2(PreferredLength, TrackSize));
slider.SetFlexUISize(FlexSize);
OnRealize?.Invoke(slider);
return slider;
}
/// <summary>
/// Creates the handle component.
/// </summary>
/// <param name="slider">The parent component.</param>
/// <returns>The sliding handle object.</returns>
private GameObject CreateHandle(GameObject slider) {
// Handle
var handle = PUIElements.CreateUI(slider, "Handle", true, PUIAnchoring.Center,
PUIAnchoring.Center);
var handleImg = handle.AddComponent<Image>();
handleImg.sprite = PUITuning.Images.SliderHandle;
handleImg.preserveAspect = true;
handle.SetUISize(new Vector2(HandleSize, HandleSize));
// Rotate the handle if needed (CCW)
float rot = 0.0f;
switch (Direction) {
case Slider.Direction.TopToBottom:
rot = 90.0f;
break;
case Slider.Direction.RightToLeft:
rot = 180.0f;
break;
case Slider.Direction.BottomToTop:
rot = 270.0f;
break;
default:
break;
}
if (rot != 0.0f)
handle.transform.Rotate(new Vector3(0.0f, 0.0f, rot));
return handle;
}
/// <summary>
/// Sets the default Klei pink button style as this slider's foreground color style.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PSliderSingle SetKleiPinkStyle() {
HandleColor = PUITuning.Colors.ButtonPinkStyle;
return this;
}
/// <summary>
/// Sets the default Klei blue button style as this slider's foreground color style.
///
/// Note that the default slider handle has a hard coded pink color.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PSliderSingle SetKleiBlueStyle() {
HandleColor = PUITuning.Colors.ButtonBlueStyle;
return this;
}
public override string ToString() {
return string.Format("PSliderSingle[Name={0}]", Name);
}
}
}

66
mod/PLibUI/PSpacer.cs Normal file
View File

@@ -0,0 +1,66 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A spacer to add into layouts. Has a large flexible width/height by default to eat all
/// the extra space.
/// </summary>
public class PSpacer : IUIComponent {
/// <summary>
/// The flexible size of this spacer. Defaults to (1, 1) but can be set to (0, 0) to
/// make this spacer a fixed size area instead.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The preferred size of this spacer.
/// </summary>
public Vector2 PreferredSize { get; set; }
public event PUIDelegates.OnRealize OnRealize;
public string Name { get; }
public PSpacer() {
Name = "Spacer";
FlexSize = Vector2.one;
PreferredSize = Vector2.zero;
}
public GameObject Build() {
var spacer = new GameObject(Name);
var le = spacer.AddComponent<LayoutElement>();
le.flexibleHeight = FlexSize.y;
le.flexibleWidth = FlexSize.x;
le.minHeight = 0.0f;
le.minWidth = 0.0f;
le.preferredHeight = PreferredSize.y;
le.preferredWidth = PreferredSize.x;
OnRealize?.Invoke(spacer);
return spacer;
}
public override string ToString() {
return "PSpacer";
}
}
}

219
mod/PLibUI/PTextArea.cs Normal file
View File

@@ -0,0 +1,219 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI text area (multi-line text field) factory class. This class should
/// probably be wrapped in a scroll pane.
/// </summary>
public sealed class PTextArea : IUIComponent {
/// <summary>
/// The text area's background color.
/// </summary>
public Color BackColor { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The preferred number of text lines to be displayed. If the component is made
/// bigger, the number of text lines (and size) can increase.
/// </summary>
public int LineCount { get; set; }
/// <summary>
/// The maximum number of characters in this text area.
/// </summary>
public int MaxLength { get; set; }
public string Name { get; }
/// <summary>
/// The minimum width in units (not characters!) of this text area.
/// </summary>
public int MinWidth { get; set; }
/// <summary>
/// The text alignment in the text area.
/// </summary>
public TextAlignmentOptions TextAlignment { get; set; }
/// <summary>
/// The initial text in the text field.
/// </summary>
public string Text { get; set; }
/// <summary>
/// The text field's text color, font, word wrap settings, and font size.
/// </summary>
public TextStyleSetting TextStyle { get; set; }
/// <summary>
/// The tool tip text.
/// </summary>
public string ToolTip { get; set; }
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The action to trigger on text change. It is passed the realized source object.
/// </summary>
public PUIDelegates.OnTextChanged OnTextChanged { get; set; }
/// <summary>
/// The callback to invoke when validating input.
/// </summary>
public TMP_InputField.OnValidateInput OnValidate { get; set; }
public PTextArea() : this(null) { }
public PTextArea(string name) {
BackColor = PUITuning.Colors.BackgroundLight;
FlexSize = Vector2.one;
LineCount = 4;
MaxLength = 1024;
MinWidth = 64;
Name = name ?? "TextArea";
Text = null;
TextAlignment = TextAlignmentOptions.TopLeft;
TextStyle = PUITuning.Fonts.TextDarkStyle;
ToolTip = "";
}
/// <summary>
/// Adds a handler when this text area is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This text area for call chaining.</returns>
public PTextArea AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
var textField = PUIElements.CreateUI(null, Name);
var style = TextStyle ?? PUITuning.Fonts.TextLightStyle;
// Background
var border = textField.AddComponent<Image>();
border.sprite = PUITuning.Images.BoxBorderWhite;
border.type = Image.Type.Sliced;
border.color = style.textColor;
// Text box with rectangular clipping area; put pivot in upper left
var textArea = PUIElements.CreateUI(textField, "Text Area", false);
textArea.AddComponent<Image>().color = BackColor;
var mask = textArea.AddComponent<RectMask2D>();
// Scrollable text
var textBox = PUIElements.CreateUI(textArea, "Text");
// Text to display
var textDisplay = PTextField.ConfigureField(textBox.AddComponent<TextMeshProUGUI>(),
style, TextAlignment);
textDisplay.enableWordWrapping = true;
textDisplay.raycastTarget = true;
// Text field itself
textField.SetActive(false);
var textEntry = textField.AddComponent<TMP_InputField>();
textEntry.textComponent = textDisplay;
textEntry.textViewport = textArea.rectTransform();
textEntry.text = Text ?? "";
textDisplay.text = Text ?? "";
// Events!
ConfigureTextEntry(textEntry);
var events = textField.AddComponent<PTextFieldEvents>();
events.OnTextChanged = OnTextChanged;
events.OnValidate = OnValidate;
events.TextObject = textBox;
// Add tooltip
PUIElements.SetToolTip(textField, ToolTip);
mask.enabled = true;
PUIElements.SetAnchorOffsets(textBox, new RectOffset());
textField.SetActive(true);
// Lay out
var layout = PUIUtils.InsetChild(textField, textArea, Vector2.one, new Vector2(
MinWidth, Math.Max(LineCount, 1) * PUIUtils.GetLineHeight(style))).
AddOrGet<LayoutElement>();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
OnRealize?.Invoke(textField);
return textField;
}
/// <summary>
/// Sets up the text entry field.
/// </summary>
/// <param name="textEntry">The input field to configure.</param>
private void ConfigureTextEntry(TMP_InputField textEntry) {
textEntry.characterLimit = Math.Max(1, MaxLength);
textEntry.enabled = true;
textEntry.inputType = TMP_InputField.InputType.Standard;
textEntry.interactable = true;
textEntry.isRichTextEditingAllowed = false;
textEntry.keyboardType = TouchScreenKeyboardType.Default;
textEntry.lineType = TMP_InputField.LineType.MultiLineNewline;
textEntry.navigation = Navigation.defaultNavigation;
textEntry.richText = false;
textEntry.selectionColor = PUITuning.Colors.SelectionBackground;
textEntry.transition = Selectable.Transition.None;
textEntry.restoreOriginalTextOnEscape = true;
}
/// <summary>
/// Sets the default Klei pink style as this text area's color and text style.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PTextArea SetKleiPinkStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the default Klei blue style as this text area's color and text style.
/// </summary>
/// <returns>This button for call chaining.</returns>
public PTextArea SetKleiBlueStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the minimum (and preferred) width of this text area in characters.
///
/// The width is computed using the currently selected text style.
/// </summary>
/// <param name="chars">The number of characters to be displayed.</param>
/// <returns>This button for call chaining.</returns>
public PTextArea SetMinWidthInCharacters(int chars) {
int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle));
if (width > 0)
MinWidth = width;
return this;
}
public override string ToString() {
return string.Format("PTextArea[Name={0}]", Name);
}
}
}

View File

@@ -0,0 +1,332 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using PeterHan.PLib.UI.Layouts;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// The abstract parent of PLib UI components which display text and/or images.
/// </summary>
public abstract class PTextComponent : IDynamicSizable {
/// <summary>
/// The center of an object for pivoting.
/// </summary>
private static readonly Vector2 CENTER = new Vector2(0.5f, 0.5f);
/// <summary>
/// Arranges a component in the parent layout in both directions.
/// </summary>
/// <param name="layout">The layout to modify.</param>
/// <param name="target">The target object to arrange.</param>
/// <param name="alignment">The object alignment to use.</param>
protected static void ArrangeComponent(RelativeLayoutGroup layout, GameObject target,
TextAnchor alignment) {
// X
switch (alignment) {
case TextAnchor.LowerLeft:
case TextAnchor.MiddleLeft:
case TextAnchor.UpperLeft:
layout.SetLeftEdge(target, fraction: 0.0f);
break;
case TextAnchor.LowerRight:
case TextAnchor.MiddleRight:
case TextAnchor.UpperRight:
layout.SetRightEdge(target, fraction: 1.0f);
break;
default:
// MiddleCenter, LowerCenter, UpperCenter
layout.AnchorXAxis(target, 0.5f);
break;
}
// Y
switch (alignment) {
case TextAnchor.LowerLeft:
case TextAnchor.LowerCenter:
case TextAnchor.LowerRight:
layout.SetBottomEdge(target, fraction: 0.0f);
break;
case TextAnchor.UpperLeft:
case TextAnchor.UpperCenter:
case TextAnchor.UpperRight:
layout.SetTopEdge(target, fraction: 1.0f);
break;
default:
// MiddleCenter, LowerCenter, UpperCenter
layout.AnchorYAxis(target, 0.5f);
break;
}
}
/// <summary>
/// Shared routine to spawn UI image objects.
/// </summary>
/// <param name="parent">The parent object for the image.</param>
/// <param name="settings">The settings to use for displaying the image.</param>
/// <returns>The child image object.</returns>
protected static Image ImageChildHelper(GameObject parent, PTextComponent settings) {
var imageChild = PUIElements.CreateUI(parent, "Image", true,
PUIAnchoring.Beginning, PUIAnchoring.Beginning);
var rt = imageChild.rectTransform();
// The pivot is important here
rt.pivot = CENTER;
var img = imageChild.AddComponent<Image>();
img.color = settings.SpriteTint;
img.sprite = settings.Sprite;
img.type = settings.SpriteMode;
img.preserveAspect = settings.MaintainSpriteAspect;
// Set up transform
var scale = Vector3.one;
float rot = 0.0f;
var rotate = settings.SpriteTransform;
if ((rotate & ImageTransform.FlipHorizontal) != ImageTransform.None)
scale.x = -1.0f;
if ((rotate & ImageTransform.FlipVertical) != ImageTransform.None)
scale.y = -1.0f;
if ((rotate & ImageTransform.Rotate90) != ImageTransform.None)
rot = 90.0f;
if ((rotate & ImageTransform.Rotate180) != ImageTransform.None)
rot += 180.0f;
// Update transform
var transform = imageChild.rectTransform();
transform.localScale = scale;
transform.Rotate(new Vector3(0.0f, 0.0f, rot));
// Limit size if needed
var imageSize = settings.SpriteSize;
if (imageSize.x > 0.0f && imageSize.y > 0.0f)
imageChild.SetUISize(imageSize, true);
return img;
}
/// <summary>
/// Shared routine to spawn UI text objects.
/// </summary>
/// <param name="parent">The parent object for the text.</param>
/// <param name="style">The text style to use.</param>
/// <param name="contents">The default text.</param>
/// <returns>The child text object.</returns>
protected static LocText TextChildHelper(GameObject parent, TextStyleSetting style,
string contents = "") {
var textChild = PUIElements.CreateUI(parent, "Text");
var locText = PUIElements.AddLocText(textChild, style);
// Font needs to be set before the text
locText.alignment = TMPro.TextAlignmentOptions.Center;
locText.text = contents;
return locText;
}
public bool DynamicSize { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The spacing between text and icon.
/// </summary>
public int IconSpacing { get; set; }
/// <summary>
/// If true, the sprite aspect ratio will be maintained even if it is resized.
/// </summary>
public bool MaintainSpriteAspect { get; set; }
/// <summary>
/// The margin around the component.
/// </summary>
public RectOffset Margin { get; set; }
public string Name { get; }
/// <summary>
/// The sprite to display, or null to display no sprite.
/// </summary>
public Sprite Sprite { get; set; }
/// <summary>
/// The image mode to use for the sprite.
/// </summary>
public Image.Type SpriteMode { get; set; }
/// <summary>
/// The position to use for the sprite relative to the text.
///
/// If TextAnchor.MiddleCenter is used, the image will directly overlap the text.
/// Otherwise, it will be placed in the specified location relative to the text.
/// </summary>
public TextAnchor SpritePosition { get; set; }
/// <summary>
/// The size to scale the sprite. If 0x0, it will not be scaled.
/// </summary>
public Vector2 SpriteSize { get; set; }
/// <summary>
/// The color to tint the sprite. For no tint, use Color.white.
/// </summary>
public Color SpriteTint { get; set; }
/// <summary>
/// How to rotate or flip the sprite.
/// </summary>
public ImageTransform SpriteTransform { get; set; }
/// <summary>
/// The component's text.
/// </summary>
public string Text { get; set; }
/// <summary>
/// The text alignment in the component. Controls the placement of the text and sprite
/// combination relative to the component's overall outline if the component is
/// expanded from its default size.
///
/// The text and sprite will move as a unit to follow this text alignment. Note that
/// incorrect positions will result if this alignment is centered in the same direction
/// as the sprite position offset, if both a sprite and text are defined.
///
/// If the SpritePosition uses any variant of Left or Right, using UpperCenter,
/// MiddleCenter, or LowerCenter for TextAlignment would result in undefined text and
/// sprite positioning. Likewise, a SpritePosition using any variant of Lower or Upper
/// would cause undefined positioning if TextAlignment was MiddleLeft, MiddleCenter,
/// or MiddleRight.
/// </summary>
public TextAnchor TextAlignment { get; set; }
/// <summary>
/// The component's text color, font, word wrap settings, and font size.
/// </summary>
public TextStyleSetting TextStyle { get; set; }
/// <summary>
/// The tool tip text.
/// </summary>
public string ToolTip { get; set; }
public event PUIDelegates.OnRealize OnRealize;
protected PTextComponent(string name) {
DynamicSize = false;
FlexSize = Vector2.zero;
IconSpacing = 0;
MaintainSpriteAspect = true;
Margin = null;
Name = name;
Sprite = null;
SpriteMode = Image.Type.Simple;
SpritePosition = TextAnchor.MiddleLeft;
SpriteSize = Vector2.zero;
SpriteTint = Color.white;
SpriteTransform = ImageTransform.None;
Text = null;
TextAlignment = TextAnchor.MiddleCenter;
TextStyle = null;
ToolTip = "";
}
public abstract GameObject Build();
/// <summary>
/// If the flex size is zero and dynamic size is false, the layout group can be
/// completely destroyed on a text component after the layout is locked.
/// </summary>
/// <param name="component">The realized text component.</param>
protected void DestroyLayoutIfPossible(GameObject component) {
if (FlexSize.x == 0.0f && FlexSize.y == 0.0f && !DynamicSize)
AbstractLayoutGroup.DestroyAndReplaceLayout(component);
}
/// <summary>
/// Invokes the OnRealize event.
/// </summary>
/// <param name="obj">The realized text component.</param>
protected void InvokeRealize(GameObject obj) {
OnRealize?.Invoke(obj);
}
public override string ToString() {
return string.Format("{3}[Name={0},Text={1},Sprite={2}]", Name, Text, Sprite,
GetType().Name);
}
/// <summary>
/// Wraps the text and sprite into a single GameObject that properly positions them
/// relative to each other, if necessary.
/// </summary>
/// <param name="text">The text component.</param>
/// <param name="sprite">The sprite component.</param>
/// <returns>A game object that contains both of them, or null if both are null.</returns>
protected GameObject WrapTextAndSprite(GameObject text, GameObject sprite) {
GameObject result = null;
if (text != null && sprite != null) {
// Automatically hoist them into a new game object
result = PUIElements.CreateUI(text.GetParent(), "AlignmentWrapper");
text.SetParent(result);
sprite.SetParent(result);
var layout = result.AddOrGet<RelativeLayoutGroup>();
// X
switch (SpritePosition) {
case TextAnchor.MiddleLeft:
case TextAnchor.LowerLeft:
case TextAnchor.UpperLeft:
layout.SetLeftEdge(sprite, fraction: 0.0f).SetLeftEdge(text, toRight:
sprite).SetMargin(sprite, new RectOffset(0, IconSpacing, 0, 0));
break;
case TextAnchor.MiddleRight:
case TextAnchor.LowerRight:
case TextAnchor.UpperRight:
layout.SetRightEdge(sprite, fraction: 1.0f).SetRightEdge(text, toLeft:
sprite).SetMargin(sprite, new RectOffset(IconSpacing, 0, 0, 0));
break;
default:
// MiddleCenter, UpperCenter, LowerCenter
layout.AnchorXAxis(text).AnchorXAxis(sprite);
break;
}
// Y
switch (SpritePosition) {
case TextAnchor.UpperCenter:
case TextAnchor.UpperLeft:
case TextAnchor.UpperRight:
layout.SetTopEdge(sprite, fraction: 1.0f).SetTopEdge(text, below:
sprite).SetMargin(sprite, new RectOffset(0, 0, 0, IconSpacing));
break;
case TextAnchor.LowerCenter:
case TextAnchor.LowerLeft:
case TextAnchor.LowerRight:
layout.SetBottomEdge(sprite, fraction: 0.0f).SetBottomEdge(text, above:
sprite).SetMargin(sprite, new RectOffset(0, 0, IconSpacing, 0));
break;
default:
// MiddleCenter, MiddleLeft, MiddleRight
layout.AnchorYAxis(text).AnchorYAxis(sprite);
break;
}
if (!DynamicSize)
layout.LockLayout();
} else if (text != null)
result = text;
else if (sprite != null)
result = sprite;
return result;
}
}
}

302
mod/PLibUI/PTextField.cs Normal file
View File

@@ -0,0 +1,302 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI text field factory class.
/// </summary>
public sealed class PTextField : IUIComponent {
/// <summary>
/// Configures a Text Mesh Pro field.
/// </summary>
/// <param name="component">The text component to configure.</param>
/// <param name="style">The desired text color, font, and style.</param>
/// <param name="alignment">The text alignment.</param>
/// <returns>The component, for call chaining.</returns>
internal static TextMeshProUGUI ConfigureField(TextMeshProUGUI component,
TextStyleSetting style, TextAlignmentOptions alignment) {
component.alignment = alignment;
component.autoSizeTextContainer = false;
component.enabled = true;
component.color = style.textColor;
component.font = style.sdfFont;
component.fontSize = style.fontSize;
component.fontStyle = style.style;
component.overflowMode = TextOverflowModes.Overflow;
return component;
}
/// <summary>
/// Gets a text field's text.
/// </summary>
/// <param name="textField">The UI element to retrieve.</param>
/// <returns>The current text in the field.</returns>
public static string GetText(GameObject textField) {
if (textField == null)
throw new ArgumentNullException(nameof(textField));
return textField.TryGetComponent(out TMP_InputField field) ? field.text : "";
}
/// <summary>
/// The text field's background color.
/// </summary>
public Color BackColor { get; set; }
/// <summary>
/// Retrieves the built-in field type used for Text Mesh Pro.
/// </summary>
private TMP_InputField.ContentType ContentType {
get {
TMP_InputField.ContentType cType;
switch (Type) {
case FieldType.Float:
cType = TMP_InputField.ContentType.DecimalNumber;
break;
case FieldType.Integer:
cType = TMP_InputField.ContentType.IntegerNumber;
break;
case FieldType.Text:
default:
cType = TMP_InputField.ContentType.Standard;
break;
}
return cType;
}
}
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The maximum number of characters in this text field.
/// </summary>
public int MaxLength { get; set; }
/// <summary>
/// The minimum width in units (not characters!) of this text field.
/// </summary>
public int MinWidth { get; set; }
/// <summary>
/// The placeholder text style (including color, font, and word wrap settings) if the
/// field is empty.
/// </summary>
public TextStyleSetting PlaceholderStyle { get; set; }
/// <summary>
/// The placeholder text if the field is empty.
/// </summary>
public string PlaceholderText { get; set; }
public string Name { get; }
/// <summary>
/// The text alignment in the text field.
/// </summary>
public TextAlignmentOptions TextAlignment { get; set; }
/// <summary>
/// The initial text in the text field.
/// </summary>
public string Text { get; set; }
/// <summary>
/// The text field's text color, font, word wrap settings, and font size.
/// </summary>
public TextStyleSetting TextStyle { get; set; }
/// <summary>
/// The tool tip text.
/// </summary>
public string ToolTip { get; set; }
/// <summary>
/// The field type.
/// </summary>
public FieldType Type { get; set; }
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The action to trigger on text change. It is passed the realized source object.
/// </summary>
public PUIDelegates.OnTextChanged OnTextChanged { get; set; }
/// <summary>
/// The callback to invoke when validating input.
/// </summary>
public TMP_InputField.OnValidateInput OnValidate { get; set; }
public PTextField() : this(null) { }
public PTextField(string name) {
BackColor = PUITuning.Colors.BackgroundLight;
FlexSize = Vector2.zero;
MaxLength = 256;
MinWidth = 32;
Name = name ?? "TextField";
PlaceholderText = null;
Text = null;
TextAlignment = TextAlignmentOptions.Center;
TextStyle = PUITuning.Fonts.TextDarkStyle;
PlaceholderStyle = TextStyle;
ToolTip = "";
Type = FieldType.Text;
}
/// <summary>
/// Adds a handler when this text field is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This text field for call chaining.</returns>
public PTextField AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
var textField = PUIElements.CreateUI(null, Name);
var style = TextStyle ?? PUITuning.Fonts.TextLightStyle;
// Background
var border = textField.AddComponent<Image>();
border.sprite = PUITuning.Images.BoxBorderWhite;
border.type = Image.Type.Sliced;
border.color = style.textColor;
// Text box with rectangular clipping area
var textArea = PUIElements.CreateUI(textField, "Text Area", false);
textArea.AddComponent<Image>().color = BackColor;
var mask = textArea.AddComponent<RectMask2D>();
// Scrollable text
var textBox = PUIElements.CreateUI(textArea, "Text");
// Text to display
var textDisplay = ConfigureField(textBox.AddComponent<TextMeshProUGUI>(), style,
TextAlignment);
textDisplay.enableWordWrapping = false;
textDisplay.maxVisibleLines = 1;
textDisplay.raycastTarget = true;
// Text field itself
textField.SetActive(false);
var textEntry = textField.AddComponent<TMP_InputField>();
textEntry.textComponent = textDisplay;
textEntry.textViewport = textArea.rectTransform();
textEntry.text = Text ?? "";
textDisplay.text = Text ?? "";
// Placeholder
if (PlaceholderText != null) {
var placeholder = PUIElements.CreateUI(textArea, "Placeholder Text");
var textPlace = ConfigureField(placeholder.AddComponent<TextMeshProUGUI>(),
PlaceholderStyle ?? style, TextAlignment);
textPlace.maxVisibleLines = 1;
textPlace.text = PlaceholderText;
textEntry.placeholder = textPlace;
}
// Events!
ConfigureTextEntry(textEntry);
var events = textField.AddComponent<PTextFieldEvents>();
events.OnTextChanged = OnTextChanged;
events.OnValidate = OnValidate;
events.TextObject = textBox;
// Add tooltip
PUIElements.SetToolTip(textField, ToolTip);
mask.enabled = true;
PUIElements.SetAnchorOffsets(textBox, new RectOffset());
textField.SetActive(true);
// Lay out
var rt = textBox.rectTransform();
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);
var layout = PUIUtils.InsetChild(textField, textArea, Vector2.one, new Vector2(
MinWidth, LayoutUtility.GetPreferredHeight(rt))).AddOrGet<LayoutElement>();
layout.flexibleWidth = FlexSize.x;
layout.flexibleHeight = FlexSize.y;
OnRealize?.Invoke(textField);
return textField;
}
/// <summary>
/// Sets up the text entry field.
/// </summary>
/// <param name="textEntry">The input field to configure.</param>
private void ConfigureTextEntry(TMP_InputField textEntry) {
textEntry.characterLimit = Math.Max(1, MaxLength);
textEntry.contentType = ContentType;
textEntry.enabled = true;
textEntry.inputType = TMP_InputField.InputType.Standard;
textEntry.interactable = true;
textEntry.isRichTextEditingAllowed = false;
textEntry.keyboardType = TouchScreenKeyboardType.Default;
textEntry.lineType = TMP_InputField.LineType.SingleLine;
textEntry.navigation = Navigation.defaultNavigation;
textEntry.richText = false;
textEntry.selectionColor = PUITuning.Colors.SelectionBackground;
textEntry.transition = Selectable.Transition.None;
textEntry.restoreOriginalTextOnEscape = true;
}
/// <summary>
/// Sets the default Klei pink style as this text field's color and text style.
/// </summary>
/// <returns>This text field for call chaining.</returns>
public PTextField SetKleiPinkStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonPinkStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the default Klei blue style as this text field's color and text style.
/// </summary>
/// <returns>This text field for call chaining.</returns>
public PTextField SetKleiBlueStyle() {
TextStyle = PUITuning.Fonts.UILightStyle;
BackColor = PUITuning.Colors.ButtonBlueStyle.inactiveColor;
return this;
}
/// <summary>
/// Sets the minimum (and preferred) width of this text field in characters.
///
/// The width is computed using the currently selected text style.
/// </summary>
/// <param name="chars">The number of characters to be displayed.</param>
/// <returns>This text field for call chaining.</returns>
public PTextField SetMinWidthInCharacters(int chars) {
int width = Mathf.RoundToInt(chars * PUIUtils.GetEmWidth(TextStyle));
if (width > 0)
MinWidth = width;
return this;
}
public override string ToString() {
return string.Format("PTextField[Name={0},Type={1}]", Name, Type);
}
/// <summary>
/// The valid text field types supported by this class.
/// </summary>
public enum FieldType {
Text, Integer, Float
}
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A class instance that handles events for text fields.
/// </summary>
internal sealed class PTextFieldEvents : KScreen {
/// <summary>
/// The action to trigger on text change. It is passed the realized source object.
/// </summary>
[SerializeField]
internal PUIDelegates.OnTextChanged OnTextChanged { get; set; }
/// <summary>
/// The callback to invoke when validating input.
/// </summary>
[SerializeField]
internal TMP_InputField.OnValidateInput OnValidate { get; set; }
/// <summary>
/// The object to resize on text change.
/// </summary>
[SerializeField]
internal GameObject TextObject { get; set; }
[MyCmpReq]
#pragma warning disable IDE0044 // Add readonly modifier
#pragma warning disable CS0649
private TMP_InputField textEntry;
#pragma warning restore CS0649
#pragma warning restore IDE0044 // Add readonly modifier
/// <summary>
/// Whether editing is in progress.
/// </summary>
private bool editing;
internal PTextFieldEvents() {
activateOnSpawn = true;
editing = false;
TextObject = null;
}
/// <summary>
/// Completes the edit process one frame after the data is entered.
/// </summary>
private System.Collections.IEnumerator DelayEndEdit() {
yield return new WaitForEndOfFrame();
StopEditing();
}
public override float GetSortKey() {
return editing ? 99.0f : base.GetSortKey();
}
protected override void OnCleanUp() {
textEntry.onFocus -= OnFocus;
textEntry.onValueChanged.RemoveListener(OnValueChanged);
textEntry.onEndEdit.RemoveListener(OnEndEdit);
base.OnCleanUp();
}
protected override void OnSpawn() {
base.OnSpawn();
textEntry.onFocus += OnFocus;
textEntry.onValueChanged.AddListener(OnValueChanged);
textEntry.onEndEdit.AddListener(OnEndEdit);
if (OnValidate != null)
textEntry.onValidateInput = OnValidate;
}
/// <summary>
/// Triggered when editing of the text ends (field loses focus).
/// </summary>
/// <param name="text">The text entered.</param>
private void OnEndEdit(string text) {
var obj = gameObject;
if (obj != null) {
OnTextChanged?.Invoke(obj, text);
if (obj.activeInHierarchy)
StartCoroutine(DelayEndEdit());
}
}
/// <summary>
/// Triggered when the text field gains focus.
/// </summary>
private void OnFocus() {
editing = true;
textEntry.Select();
textEntry.ActivateInputField();
KScreenManager.Instance.RefreshStack();
}
/// <summary>
/// Destroys events if editing is in progress to prevent bubbling through to the
/// game UI.
/// </summary>
public override void OnKeyDown(KButtonEvent e) {
if (editing)
e.Consumed = true;
else
base.OnKeyDown(e);
}
/// <summary>
/// Destroys events if editing is in progress to prevent bubbling through to the
/// game UI.
/// </summary>
public override void OnKeyUp(KButtonEvent e) {
if (editing)
e.Consumed = true;
else
base.OnKeyUp(e);
}
/// <summary>
/// Triggered when the text box value changes.
/// </summary>
/// <param name="text">The text entered.</param>
private void OnValueChanged(string text) {
var obj = gameObject;
if (obj != null && TextObject != null) {
var rt = TextObject.rectTransform();
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, LayoutUtility.
GetPreferredHeight(rt));
}
}
/// <summary>
/// Completes the edit process.
/// </summary>
private void StopEditing() {
if (textEntry != null && textEntry.gameObject.activeInHierarchy)
textEntry.DeactivateInputField();
editing = false;
}
}
}

174
mod/PLibUI/PToggle.cs Normal file
View File

@@ -0,0 +1,174 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// A custom UI toggled button factory class.
/// </summary>
public sealed class PToggle : IDynamicSizable {
/// <summary>
/// The default margins around a toggle.
/// </summary>
internal static readonly RectOffset TOGGLE_MARGIN = new RectOffset(1, 1, 1, 1);
/// <summary>
/// Gets a realized toggle button's state.
/// </summary>
/// <param name="realized">The realized toggle button.</param>
/// <returns>The toggle button state.</returns>
public static bool GetToggleState(GameObject realized) {
return realized != null && realized.TryGetComponent(out KToggle toggle) &&
UIDetours.IS_ON.Get(toggle);
}
/// <summary>
/// Sets a realized toggle button's state.
/// </summary>
/// <param name="realized">The realized toggle button.</param>
/// <param name="on">Whether the button should be on or off.</param>
public static void SetToggleState(GameObject realized, bool on) {
if (realized != null && realized.TryGetComponent(out KToggle toggle))
UIDetours.IS_ON.Set(toggle, on);
}
/// <summary>
/// The sprite to display when active.
/// </summary>
public Sprite ActiveSprite { get; set; }
/// <summary>
/// The toggle's color.
/// </summary>
public ColorStyleSetting Color { get; set; }
public bool DynamicSize { get; set; }
/// <summary>
/// The flexible size bounds of this component.
/// </summary>
public Vector2 FlexSize { get; set; }
/// <summary>
/// The sprite to display when inactive.
/// </summary>
public Sprite InactiveSprite { get; set; }
/// <summary>
/// The initial state of the toggle button.
/// </summary>
public bool InitialState { get; set; }
/// <summary>
/// The margin around the component.
/// </summary>
public RectOffset Margin { get; set; }
public string Name { get; }
/// <summary>
/// The action to trigger when the state changes. It is passed the realized source
/// object.
/// </summary>
public PUIDelegates.OnToggleButton OnStateChanged { get; set; }
/// <summary>
/// The size to scale the toggle images. If 0x0, it will not be scaled.
/// </summary>
public Vector2 Size { get; set; }
/// <summary>
/// The tool tip text.
/// </summary>
public string ToolTip { get; set; }
public event PUIDelegates.OnRealize OnRealize;
public PToggle() : this(null) { }
public PToggle(string name) {
ActiveSprite = PUITuning.Images.Contract;
Color = PUITuning.Colors.ComponentDarkStyle;
InitialState = false;
Margin = TOGGLE_MARGIN;
Name = name ?? "Toggle";
InactiveSprite = PUITuning.Images.Expand;
ToolTip = "";
}
/// <summary>
/// Adds a handler when this toggle button is realized.
/// </summary>
/// <param name="onRealize">The handler to invoke on realization.</param>
/// <returns>This toggle button for call chaining.</returns>
public PToggle AddOnRealize(PUIDelegates.OnRealize onRealize) {
OnRealize += onRealize;
return this;
}
public GameObject Build() {
var toggle = PUIElements.CreateUI(null, Name);
// Set on click event
var kToggle = toggle.AddComponent<KToggle>();
var evt = OnStateChanged;
if (evt != null)
kToggle.onValueChanged += (on) => {
evt?.Invoke(toggle, on);
};
UIDetours.ART_EXTENSION.Set(kToggle, new KToggleArtExtensions());
UIDetours.SOUND_PLAYER_TOGGLE.Set(kToggle, PUITuning.ToggleSounds);
// Background image
var fgImage = toggle.AddComponent<Image>();
fgImage.color = Color.activeColor;
fgImage.sprite = InactiveSprite;
toggle.SetActive(false);
// Toggled images
var toggleImage = toggle.AddComponent<ImageToggleState>();
toggleImage.TargetImage = fgImage;
toggleImage.useSprites = true;
toggleImage.InactiveSprite = InactiveSprite;
toggleImage.ActiveSprite = ActiveSprite;
toggleImage.startingState = InitialState ? ImageToggleState.State.Active :
ImageToggleState.State.Inactive;
toggleImage.useStartingState = true;
toggleImage.ActiveColour = Color.activeColor;
toggleImage.DisabledActiveColour = Color.disabledActiveColor;
toggleImage.InactiveColour = Color.inactiveColor;
toggleImage.DisabledColour = Color.disabledColor;
toggleImage.HoverColour = Color.hoverColor;
toggleImage.DisabledHoverColor = Color.disabledhoverColor;
UIDetours.IS_ON.Set(kToggle, InitialState);
toggle.SetActive(true);
// Set size
if (Size.x > 0.0f && Size.y > 0.0f)
toggle.SetUISize(Size, true);
else
PUIElements.AddSizeFitter(toggle, DynamicSize);
// Add tooltip
PUIElements.SetToolTip(toggle, ToolTip).SetFlexUISize(FlexSize).SetActive(true);
OnRealize?.Invoke(toggle);
return toggle;
}
public override string ToString() {
return string.Format("PToggle[Name={0}]", Name);
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// Delegate types used in the UI event system.
/// </summary>
public sealed class PUIDelegates {
/// <summary>
/// The delegate type invoked when a dialog is closed.
/// </summary>
/// <param name="option">The key of the chosen option, or PDialog.DIALOG_CLOSE_KEY if
/// the dialog was closed with ESC or the X button.</param>
public delegate void OnDialogClosed(string option);
/// <summary>
/// The delegate type invoked when a button is pressed.
/// </summary>
/// <param name="source">The source button.</param>
public delegate void OnButtonPressed(GameObject source);
/// <summary>
/// The delegate type invoked when a checkbox is clicked.
/// </summary>
/// <param name="source">The source button.</param>
/// <param name="state">The checkbox state.</param>
public delegate void OnChecked(GameObject source, int state);
/// <summary>
/// The delegate type invoked when an option is selected from a combo box.
/// </summary>
/// <param name="source">The source dropdown.</param>
/// <param name="choice">The option chosen.</param>
/// <typeparam name="T">The type of the objects in the drop down.</typeparam>
public delegate void OnDropdownChanged<T>(GameObject source, T choice) where T :
class, IListableOption;
/// <summary>
/// The delegate type invoked when components are converted into Unity game objects.
/// </summary>
/// <param name="realized">The realized object.</param>
public delegate void OnRealize(GameObject realized);
/// <summary>
/// The delegate type invoked once after a slider is changed and released.
/// </summary>
/// <param name="source">The source slider.</param>
/// <param name="newValue">The new slider value.</param>
public delegate void OnSliderChanged(GameObject source, float newValue);
/// <summary>
/// The delegate type invoked while a slider is being changed.
/// </summary>
/// <param name="source">The source slider.</param>
/// <param name="newValue">The new slider value.</param>
public delegate void OnSliderDrag(GameObject source, float newValue);
/// <summary>
/// The delegate type invoked when text in a text field is changed.
/// </summary>
/// <param name="source">The source text field.</param>
/// <param name="text">The new text.</param>
public delegate void OnTextChanged(GameObject source, string text);
/// <summary>
/// The delegate type invoked when a toggle button is swapped between states.
/// </summary>
/// <param name="source">The source button.</param>
/// <param name="on">true if the button is toggled on, or false otherwise.</param>
public delegate void OnToggleButton(GameObject source, bool on);
}
}

357
mod/PLibUI/PUIElements.cs Normal file
View File

@@ -0,0 +1,357 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using PeterHan.PLib.Detours;
using System;
using UnityEngine;
using UnityEngine.UI;
using ContentFitMode = UnityEngine.UI.ContentSizeFitter.FitMode;
namespace PeterHan.PLib.UI {
/// <summary>
/// Used for creating and managing UI elements.
/// </summary>
public sealed class PUIElements {
/// <summary>
/// Safely adds a LocText to a game object without throwing an NRE on construction.
/// </summary>
/// <param name="parent">The game object to add the LocText.</param>
/// <param name="setting">The text style.</param>
/// <returns>The added LocText object.</returns>
internal static LocText AddLocText(GameObject parent, TextStyleSetting setting = null)
{
if (parent == null)
throw new ArgumentNullException(nameof(parent));
bool active = parent.activeSelf;
parent.SetActive(false);
var text = parent.AddComponent<LocText>();
// This is enough to let it activate
UIDetours.LOCTEXT_KEY.Set(text, string.Empty);
UIDetours.LOCTEXT_STYLE.Set(text, setting ?? PUITuning.Fonts.UIDarkStyle);
parent.SetActive(active);
return text;
}
/// <summary>
/// Adds an auto-fit resizer to a UI element.
///
/// UI elements should be active before any layouts are added, especially if they are
/// to be frozen.
/// </summary>
/// <param name="uiElement">The element to resize.</param>
/// <param name="dynamic">true to use the Unity content size fitter which adjusts to
/// content changes, or false to set the size only once.</param>
/// <param name="modeHoriz">The sizing mode to use in the horizontal direction.</param>
/// <param name="modeVert">The sizing mode to use in the vertical direction.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject AddSizeFitter(GameObject uiElement, bool dynamic = false,
ContentFitMode modeHoriz = ContentFitMode.PreferredSize,
ContentFitMode modeVert = ContentFitMode.PreferredSize) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
if (dynamic) {
var fitter = uiElement.AddOrGet<ContentSizeFitter>();
fitter.horizontalFit = modeHoriz;
fitter.verticalFit = modeVert;
fitter.enabled = true;
} else
FitSizeNow(uiElement, modeHoriz, modeVert);
return uiElement;
}
/// <summary>
/// Creates a UI game object.
/// </summary>
/// <param name="parent">The parent of the UI object. If not set now, or added/changed
/// later, the anchors must be redefined.</param>
/// <param name="name">The object name.</param>
/// <param name="canvas">true to add a canvas renderer, or false otherwise.</param>
/// <param name="horizAnchor">How to anchor the object horizontally.</param>
/// <param name="vertAnchor">How to anchor the object vertically.</param>
/// <returns>The UI object with transform and canvas initialized.</returns>
public static GameObject CreateUI(GameObject parent, string name, bool canvas = true,
PUIAnchoring horizAnchor = PUIAnchoring.Stretch,
PUIAnchoring vertAnchor = PUIAnchoring.Stretch) {
var element = new GameObject(name);
if (parent != null)
element.SetParent(parent);
// Size and position
var transform = element.AddOrGet<RectTransform>();
transform.localScale = Vector3.one;
SetAnchors(element, horizAnchor, vertAnchor);
// Almost all UI components need a canvas renderer for some reason
if (canvas)
element.AddComponent<CanvasRenderer>();
element.layer = LayerMask.NameToLayer("UI");
return element;
}
/// <summary>
/// Does nothing, to make the buttons appear.
/// </summary>
private static void DoNothing() { }
/// <summary>
/// Fits the UI element's size immediately, as if ContentSizeFitter was created on it,
/// but does not create a component and only affects the size once.
/// </summary>
/// <param name="uiElement">The element to resize.</param>
/// <param name="modeHoriz">The sizing mode to use in the horizontal direction.</param>
/// <param name="modeVert">The sizing mode to use in the vertical direction.</param>
/// <returns>The UI element, for call chaining.</returns>
private static void FitSizeNow(GameObject uiElement, ContentFitMode modeHoriz,
ContentFitMode modeVert) {
float width = 0.0f, height = 0.0f;
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
// Follow order in https://docs.unity3d.com/Manual/UIAutoLayout.html
var elements = uiElement.GetComponents<ILayoutElement>();
var constraints = uiElement.AddOrGet<LayoutElement>();
var rt = uiElement.AddOrGet<RectTransform>();
// Calculate horizontal
foreach (var layoutElement in elements)
layoutElement.CalculateLayoutInputHorizontal();
if (modeHoriz != ContentFitMode.Unconstrained) {
// Layout horizontal
foreach (var layoutElement in elements)
switch (modeHoriz) {
case ContentFitMode.MinSize:
width = Math.Max(width, layoutElement.minWidth);
break;
case ContentFitMode.PreferredSize:
width = Math.Max(width, layoutElement.preferredWidth);
break;
default:
break;
}
width = Math.Max(width, constraints.minWidth);
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
constraints.minWidth = width;
constraints.flexibleWidth = 0.0f;
}
// Calculate vertical
foreach (var layoutElement in elements)
layoutElement.CalculateLayoutInputVertical();
if (modeVert != ContentFitMode.Unconstrained) {
// Layout vertical
foreach (var layoutElement in elements)
switch (modeVert) {
case ContentFitMode.MinSize:
height = Math.Max(height, layoutElement.minHeight);
break;
case ContentFitMode.PreferredSize:
height = Math.Max(height, layoutElement.preferredHeight);
break;
default:
break;
}
height = Math.Max(height, constraints.minHeight);
rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
constraints.minHeight = height;
constraints.flexibleHeight = 0.0f;
}
}
/// <summary>
/// Sets the anchor location of a UI element. The offsets will be reset, use
/// SetAnchorOffsets to adjust the offset from the new anchor locations.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="horizAnchor">The horizontal anchor mode.</param>
/// <param name="vertAnchor">The vertical anchor mode.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetAnchors(GameObject uiElement, PUIAnchoring horizAnchor,
PUIAnchoring vertAnchor) {
Vector2 aMax = new Vector2(), aMin = new Vector2(), pivot = new Vector2();
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
var transform = uiElement.rectTransform();
// Anchor: horizontal
switch (horizAnchor) {
case PUIAnchoring.Center:
aMin.x = 0.5f;
aMax.x = 0.5f;
pivot.x = 0.5f;
break;
case PUIAnchoring.End:
aMin.x = 1.0f;
aMax.x = 1.0f;
pivot.x = 1.0f;
break;
case PUIAnchoring.Stretch:
aMin.x = 0.0f;
aMax.x = 1.0f;
pivot.x = 0.5f;
break;
default:
aMin.x = 0.0f;
aMax.x = 0.0f;
pivot.x = 0.0f;
break;
}
// Anchor: vertical
switch (vertAnchor) {
case PUIAnchoring.Center:
aMin.y = 0.5f;
aMax.y = 0.5f;
pivot.y = 0.5f;
break;
case PUIAnchoring.End:
aMin.y = 1.0f;
aMax.y = 1.0f;
pivot.y = 1.0f;
break;
case PUIAnchoring.Stretch:
aMin.y = 0.0f;
aMax.y = 1.0f;
pivot.y = 0.5f;
break;
default:
aMin.y = 0.0f;
aMax.y = 0.0f;
pivot.y = 0.0f;
break;
}
transform.anchorMax = aMax;
transform.anchorMin = aMin;
transform.pivot = pivot;
transform.anchoredPosition = Vector2.zero;
transform.offsetMax = Vector2.zero;
transform.offsetMin = Vector2.zero;
return uiElement;
}
/// <summary>
/// Sets the offsets of the UI component from its anchors. Positive for each value
/// denotes towards the component center, and negative away from the component center.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="border">The offset of each corner from the anchors.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetAnchorOffsets(GameObject uiElement, RectOffset border) {
return SetAnchorOffsets(uiElement, border.left, border.right, border.top, border.
bottom);
}
/// <summary>
/// Sets the offsets of the UI component from its anchors. Positive for each value
/// denotes towards the component center, and negative away from the component center.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="left">The left border in pixels.</param>
/// <param name="right">The right border in pixels.</param>
/// <param name="top">The top border in pixels.</param>
/// <param name="bottom">The bottom border in pixels.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetAnchorOffsets(GameObject uiElement, float left,
float right, float top, float bottom) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
var transform = uiElement.rectTransform();
transform.offsetMin = new Vector2(left, bottom);
transform.offsetMax = new Vector2(-right, -top);
return uiElement;
}
/// <summary>
/// Sets a UI element's text.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="text">The text to display on the element.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetText(GameObject uiElement, string text) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
var lt = uiElement.GetComponentInChildren<TMPro.TextMeshProUGUI>();
if (lt != null)
lt.SetText(text ?? string.Empty);
return uiElement;
}
/// <summary>
/// Sets a UI element's tool tip.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="tooltip">The tool tip text to display when hovered.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetToolTip(GameObject uiElement, string tooltip) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
if (!string.IsNullOrEmpty(tooltip))
uiElement.AddOrGet<ToolTip>().toolTip = tooltip;
return uiElement;
}
/// <summary>
/// Shows a confirmation dialog.
/// </summary>
/// <param name="parent">The dialog's parent.</param>
/// <param name="message">The message to display.</param>
/// <param name="onConfirm">The action to invoke if Yes or OK is selected.</param>
/// <param name="onCancel">The action to invoke if No or Cancel is selected.</param>
/// <param name="confirmText">The text for the OK/Yes button.</param>
/// <param name="cancelText">The text for the Cancel/No button.</param>
/// <returns>The dialog created.</returns>
public static ConfirmDialogScreen ShowConfirmDialog(GameObject parent, string message,
System.Action onConfirm, System.Action onCancel = null,
string confirmText = null, string cancelText = null) {
if (parent == null)
parent = PDialog.GetParentObject();
var obj = Util.KInstantiateUI(ScreenPrefabs.Instance.ConfirmDialogScreen.
gameObject, parent, false);
if (obj.TryGetComponent(out ConfirmDialogScreen confirmDialog)) {
UIDetours.POPUP_CONFIRM.Invoke(confirmDialog, message, onConfirm, onCancel ??
DoNothing, null, null, null, confirmText, cancelText);
obj.SetActive(true);
} else
confirmDialog = null;
return confirmDialog;
}
/// <summary>
/// Shows a message dialog.
/// </summary>
/// <param name="parent">The dialog's parent.</param>
/// <param name="message">The message to display.</param>
/// <returns>The dialog created.</returns>
public static ConfirmDialogScreen ShowMessageDialog(GameObject parent, string message)
{
return ShowConfirmDialog(parent, message, DoNothing);
}
// This class should probably be static, but that might be binary compatibility
// breaking
private PUIElements() { }
}
/// <summary>
/// The anchor mode to set a UI component.
/// </summary>
public enum PUIAnchoring {
// Stretch to all
Stretch,
// Relative to beginning
Beginning,
// Relative to center
Center,
// Relative to end
End
}
}

432
mod/PLibUI/PUITuning.cs Normal file
View File

@@ -0,0 +1,432 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace PeterHan.PLib.UI {
/// <summary>
/// Sets up common parameters for the UI in PLib based mods. Note that this class is still
/// specific to individual mods so the values in the latest PLib will not supersede them.
/// </summary>
public static class PUITuning {
/// <summary>
/// UI images.
/// </summary>
public static class Images {
/// <summary>
/// The right arrow image. Rotate it in the Image to get more directions.
/// </summary>
public static Sprite Arrow { get; }
/// <summary>
/// The image used to make a 1px solid black border.
/// </summary>
public static Sprite BoxBorder { get; }
/// <summary>
/// The image used to make a 1px solid white border.
/// </summary>
public static Sprite BoxBorderWhite { get; }
/// <summary>
/// The default image used for button appearance.
/// </summary>
public static Sprite ButtonBorder { get; }
/// <summary>
/// The border image around a checkbox.
/// </summary>
public static Sprite CheckBorder { get; }
/// <summary>
/// The image for a check box which is checked.
/// </summary>
public static Sprite Checked { get; }
/// <summary>
/// The image used for dialog close buttons.
/// </summary>
public static Sprite Close { get; }
/// <summary>
/// The image for contracting a category.
/// </summary>
public static Sprite Contract { get; }
/// <summary>
/// The image for expanding a category.
/// </summary>
public static Sprite Expand { get; }
/// <summary>
/// The image for a check box which is neither checked nor unchecked.
/// </summary>
public static Sprite Partial { get; }
/// <summary>
/// The border of a horizontal scroll bar.
/// </summary>
public static Sprite ScrollBorderHorizontal { get; }
/// <summary>
/// The handle of a horizontal scroll bar.
/// </summary>
public static Sprite ScrollHandleHorizontal { get; }
/// <summary>
/// The border of a vertical scroll bar.
/// </summary>
public static Sprite ScrollBorderVertical { get; }
/// <summary>
/// The handle of a vertical scroll bar.
/// </summary>
public static Sprite ScrollHandleVertical { get; }
/// <summary>
/// The handle of a horizontal slider.
/// </summary>
public static Sprite SliderHandle { get; }
/// <summary>
/// The sprite dictionary.
/// </summary>
private static readonly IDictionary<string, Sprite> SPRITES;
static Images() {
SPRITES = new Dictionary<string, Sprite>(512);
// List out all sprites shipped with the game
foreach (var img in Resources.FindObjectsOfTypeAll<Sprite>()) {
string name = img?.name;
if (!string.IsNullOrEmpty(name) && !SPRITES.ContainsKey(name))
SPRITES.Add(name, img);
}
Arrow = GetSpriteByName("game_speed_play");
BoxBorder = GetSpriteByName("web_box");
BoxBorderWhite = GetSpriteByName("web_border");
ButtonBorder = GetSpriteByName("web_button");
CheckBorder = GetSpriteByName("overview_jobs_skill_box");
Checked = GetSpriteByName("overview_jobs_icon_checkmark");
Close = GetSpriteByName("cancel");
Contract = GetSpriteByName("iconDown");
Expand = GetSpriteByName("iconRight");
Partial = GetSpriteByName("overview_jobs_icon_mixed");
ScrollBorderHorizontal = GetSpriteByName("build_menu_scrollbar_frame_horizontal");
ScrollHandleHorizontal = GetSpriteByName("build_menu_scrollbar_inner_horizontal");
ScrollBorderVertical = GetSpriteByName("build_menu_scrollbar_frame");
ScrollHandleVertical = GetSpriteByName("build_menu_scrollbar_inner");
SliderHandle = GetSpriteByName("game_speed_selected_med");
}
/// <summary>
/// Retrieves a sprite by its name.
/// </summary>
/// <param name="name">The sprite name.</param>
/// <returns>The matching sprite, or null if no sprite found in the resources has that name.</returns>
public static Sprite GetSpriteByName(string name) {
if (!SPRITES.TryGetValue(name, out Sprite sprite))
sprite = null;
return sprite;
}
}
/// <summary>
/// UI colors.
/// </summary>
public static class Colors {
/// <summary>
/// A white color used for default backgrounds.
/// </summary>
public static Color BackgroundLight { get; }
/// <summary>
/// The color styles used on pink buttons.
/// </summary>
public static ColorStyleSetting ButtonPinkStyle { get; }
/// <summary>
/// The color styles used on blue buttons.
/// </summary>
public static ColorStyleSetting ButtonBlueStyle { get; }
/// <summary>
/// The default colors used on check boxes / toggles with dark backgrounds.
/// </summary>
public static ColorStyleSetting ComponentDarkStyle { get; }
/// <summary>
/// The default colors used on check boxes / toggles with white backgrounds.
/// </summary>
public static ColorStyleSetting ComponentLightStyle { get; }
/// <summary>
/// The color displayed on dialog backgrounds.
/// </summary>
public static Color DialogBackground { get; }
/// <summary>
/// The color displayed in the large border around the outsides of options dialogs.
/// </summary>
public static Color DialogDarkBackground { get; }
/// <summary>
/// The color displayed on options dialog backgrounds.
/// </summary>
public static Color OptionsBackground { get; }
/// <summary>
/// The color displayed on scrollbar handles.
/// </summary>
public static ColorBlock ScrollbarColors { get; }
/// <summary>
/// The background color for selections.
/// </summary>
public static Color SelectionBackground { get; }
/// <summary>
/// The foreground color for selections.
/// </summary>
public static Color SelectionForeground { get; }
/// <summary>
/// A completely transparent color.
/// </summary>
public static Color Transparent { get; }
/// <summary>
/// Used for dark-colored UI text.
/// </summary>
public static Color UITextDark { get; }
/// <summary>
/// Used for light-colored UI text.
/// </summary>
public static Color UITextLight { get; }
static Colors() {
BackgroundLight = new Color32(255, 255, 255, 255);
DialogBackground = new Color32(0, 0, 0, 255);
DialogDarkBackground = new Color32(48, 52, 67, 255);
OptionsBackground = new Color32(31, 34, 43, 255);
SelectionBackground = new Color32(189, 218, 255, 255);
SelectionForeground = new Color32(0, 0, 0, 255);
Transparent = new Color32(255, 255, 255, 0);
UITextLight = new Color32(255, 255, 255, 255);
UITextDark = new Color32(0, 0, 0, 255);
// Check boxes
Color active = new Color(0.0f, 0.0f, 0.0f), disabled = new Color(0.784f,
0.784f, 0.784f, 1.0f);
ComponentLightStyle = ScriptableObject.CreateInstance<ColorStyleSetting>();
ComponentLightStyle.activeColor = active;
ComponentLightStyle.inactiveColor = active;
ComponentLightStyle.hoverColor = active;
ComponentLightStyle.disabledActiveColor = disabled;
ComponentLightStyle.disabledColor = disabled;
ComponentLightStyle.disabledhoverColor = disabled;
active = new Color(1.0f, 1.0f, 1.0f);
ComponentDarkStyle = ScriptableObject.CreateInstance<ColorStyleSetting>();
ComponentDarkStyle.activeColor = active;
ComponentDarkStyle.inactiveColor = active;
ComponentDarkStyle.hoverColor = active;
ComponentDarkStyle.disabledActiveColor = disabled;
ComponentDarkStyle.disabledColor = disabled;
ComponentDarkStyle.disabledhoverColor = disabled;
// Buttons: pink
ButtonPinkStyle = ScriptableObject.CreateInstance<ColorStyleSetting>();
ButtonPinkStyle.activeColor = new Color(0.7941176f, 0.4496107f, 0.6242238f);
ButtonPinkStyle.inactiveColor = new Color(0.5294118f, 0.2724914f, 0.4009516f);
ButtonPinkStyle.disabledColor = new Color(0.4156863f, 0.4117647f, 0.4f);
ButtonPinkStyle.disabledActiveColor = Transparent;
ButtonPinkStyle.hoverColor = new Color(0.6176471f, 0.3315311f, 0.4745891f);
ButtonPinkStyle.disabledhoverColor = new Color(0.5f, 0.5f, 0.5f);
// Buttons: blue
ButtonBlueStyle = ScriptableObject.CreateInstance<ColorStyleSetting>();
ButtonBlueStyle.activeColor = new Color(0.5033521f, 0.5444419f, 0.6985294f);
ButtonBlueStyle.inactiveColor = new Color(0.2431373f, 0.2627451f, 0.3411765f);
ButtonBlueStyle.disabledColor = new Color(0.4156863f, 0.4117647f, 0.4f);
ButtonBlueStyle.disabledActiveColor = new Color(0.625f, 0.6158088f, 0.5882353f);
ButtonBlueStyle.hoverColor = new Color(0.3461289f, 0.3739619f, 0.4852941f);
ButtonBlueStyle.disabledhoverColor = new Color(0.5f, 0.4898898f, 0.4595588f);
// Scrollbars
ScrollbarColors = new ColorBlock {
colorMultiplier = 1.0f,
fadeDuration = 0.1f,
disabledColor = new Color(0.392f, 0.392f, 0.392f),
highlightedColor = new Color32(161, 163, 174, 255),
normalColor = new Color32(161, 163, 174, 255),
pressedColor = BackgroundLight
};
}
}
/// <summary>
/// Collects references to fonts in the game.
/// </summary>
public static class Fonts {
/// <summary>
/// The text font name.
/// </summary>
private const string DEFAULT_FONT_TEXT = "NotoSans-Regular";
/// <summary>
/// The UI font name.
/// </summary>
private const string DEFAULT_FONT_UI = "GRAYSTROKE REGULAR SDF";
/// <summary>
/// The default font size.
/// </summary>
public static int DefaultSize { get; }
/// <summary>
/// The default font asset for text strings.
/// </summary>
private static readonly TMP_FontAsset DefaultTextFont;
/// <summary>
/// The default font asset for UI titles and buttons.
/// </summary>
private static readonly TMP_FontAsset DefaultUIFont;
/// <summary>
/// The font used on text.
/// </summary>
internal static TMP_FontAsset Text {
get {
TMP_FontAsset font = null;
if (Localization.GetSelectedLanguageType() != Localization.
SelectedLanguageType.None)
font = Localization.FontAsset;
return font ?? DefaultTextFont;
}
}
/// <summary>
/// The text styles used on all items with a light background.
/// </summary>
public static TextStyleSetting TextDarkStyle { get; }
/// <summary>
/// The text styles used on all items with a dark background.
/// </summary>
public static TextStyleSetting TextLightStyle { get; }
/// <summary>
/// The font used on UI elements.
/// </summary>
internal static TMP_FontAsset UI {
get {
TMP_FontAsset font = null;
if (Localization.GetSelectedLanguageType() != Localization.
SelectedLanguageType.None)
font = Localization.FontAsset;
return font ?? DefaultUIFont;
}
}
/// <summary>
/// The text styles used on all UI items with a light background.
/// </summary>
public static TextStyleSetting UIDarkStyle { get; }
/// <summary>
/// The text styles used on all UI items with a dark background.
/// </summary>
public static TextStyleSetting UILightStyle { get; }
/// <summary>
/// The font dictionary.
/// </summary>
private static readonly IDictionary<string, TMP_FontAsset> FONTS;
static Fonts() {
FONTS = new Dictionary<string, TMP_FontAsset>(16);
// List out all fonts shipped with the game
foreach (var newFont in Resources.FindObjectsOfTypeAll<TMP_FontAsset>()) {
string name = newFont?.name;
if (!string.IsNullOrEmpty(name) && !FONTS.ContainsKey(name))
FONTS.Add(name, newFont);
}
// Initialization: UI fonts
if ((DefaultTextFont = GetFontByName(DEFAULT_FONT_TEXT)) == null)
PUIUtils.LogUIWarning("Unable to find font " + DEFAULT_FONT_TEXT);
if ((DefaultUIFont = GetFontByName(DEFAULT_FONT_UI)) == null)
PUIUtils.LogUIWarning("Unable to find font " + DEFAULT_FONT_UI);
// Initialization: Text style
DefaultSize = 14;
TextDarkStyle = ScriptableObject.CreateInstance<TextStyleSetting>();
TextDarkStyle.enableWordWrapping = false;
TextDarkStyle.fontSize = DefaultSize;
TextDarkStyle.sdfFont = Text;
TextDarkStyle.style = FontStyles.Normal;
TextDarkStyle.textColor = Colors.UITextDark;
TextLightStyle = TextDarkStyle.DeriveStyle(newColor: Colors.UITextLight);
UIDarkStyle = ScriptableObject.CreateInstance<TextStyleSetting>();
UIDarkStyle.enableWordWrapping = false;
UIDarkStyle.fontSize = DefaultSize;
UIDarkStyle.sdfFont = UI;
UIDarkStyle.style = FontStyles.Normal;
UIDarkStyle.textColor = Colors.UITextDark;
UILightStyle = UIDarkStyle.DeriveStyle(newColor: Colors.UITextLight);
}
/// <summary>
/// Retrieves a font by its name.
/// </summary>
/// <param name="name">The font name.</param>
/// <returns>The matching font, or null if no font found in the resources has that name.</returns>
internal static TMP_FontAsset GetFontByName(string name) {
if (!FONTS.TryGetValue(name, out TMP_FontAsset font))
font = null;
return font;
}
}
/// <summary>
/// The sounds played by the button.
/// </summary>
internal static ButtonSoundPlayer ButtonSounds { get; }
/// <summary>
/// The sounds played by the toggle.
/// </summary>
internal static ToggleSoundPlayer ToggleSounds { get; }
static PUITuning() {
// Initialization: Button sounds
ButtonSounds = new ButtonSoundPlayer() {
Enabled = true
};
ToggleSounds = new ToggleSoundPlayer();
}
}
}

801
mod/PLibUI/PUIUtils.cs Normal file
View File

@@ -0,0 +1,801 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Core;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using SideScreenRef = DetailsScreen.SideScreenRef;
namespace PeterHan.PLib.UI {
/// <summary>
/// Utility functions for dealing with Unity UIs.
/// </summary>
public static class PUIUtils {
/// <summary>
/// Adds text describing a particular component if available.
/// </summary>
/// <param name="result">The location to append the text.</param>
/// <param name="component">The component to describe.</param>
private static void AddComponentText(StringBuilder result, Component component) {
// Include all fields
var fields = component.GetType().GetFields(BindingFlags.DeclaredOnly |
BindingFlags.Instance | PPatchTools.BASE_FLAGS);
// Class specific
if (component is TMP_Text lt)
result.AppendFormat(", Text={0}, Color={1}, Font={2}", lt.text, lt.color,
lt.font);
else if (component is Image im) {
result.AppendFormat(", Color={0}", im.color);
if (im.sprite != null)
result.AppendFormat(", Sprite={0}", im.sprite);
} else if (component is HorizontalOrVerticalLayoutGroup lg)
result.AppendFormat(", Child Align={0}, Control W={1}, Control H={2}",
lg.childAlignment, lg.childControlWidth, lg.childControlHeight);
foreach (var field in fields) {
object value = field.GetValue(component) ?? "null";
// Value type specific
if (value is LayerMask lm)
value = "Layer #" + lm.value;
else if (value is System.Collections.ICollection ic)
value = "[" + ic.Join() + "]";
else if (value is Array ar)
value = "[" + ar.Join() + "]";
result.AppendFormat(", {0}={1}", field.Name, value);
}
}
/// <summary>
/// Adds a hot pink rectangle over the target matching its size, to help identify it
/// better.
/// </summary>
/// <param name="parent">The target UI component.</param>
public static void AddPinkOverlay(GameObject parent) {
var child = PUIElements.CreateUI(parent, "Overlay");
var img = child.AddComponent<Image>();
img.color = new Color(1.0f, 0.0f, 1.0f, 0.2f);
}
/// <summary>
/// Adds the specified side screen content to the side screen list. The side screen
/// behavior should be defined in a class inherited from SideScreenContent.
///
/// The side screen will be added at the end of the list, which will cause it to
/// appear above previous side screens in the details panel.
///
/// This method should be used in a postfix on DetailsScreen.OnPrefabInit.
/// </summary>
/// <typeparam name="T">The type of the controller that will determine how the side
/// screen works. A new instance will be created and added as a component to the new
/// side screen.</typeparam>
/// <param name="uiPrefab">The UI prefab to use. If null is passed, the UI should
/// be created and added to the GameObject hosting the controller object in its
/// constructor.</param>
public static void AddSideScreenContent<T>(GameObject uiPrefab = null)
where T : SideScreenContent {
AddSideScreenContentWithOrdering<T>(null, true, uiPrefab);
}
/// <summary>
/// Adds the specified side screen content to the side screen list. The side screen
/// behavior should be defined in a class inherited from SideScreenContent.
///
/// This method should be used in a postfix on DetailsScreen.OnPrefabInit.
/// </summary>
/// <typeparam name="T">The type of the controller that will determine how the side
/// screen works. A new instance will be created and added as a component to the new
/// side screen.</typeparam>
/// <param name="targetClassName">The full name of the type of side screen to based to ordering
/// around. An example of how this method can be used is:
/// `AddSideScreenContentWithOrdering&lt;MySideScreen&gt;(typeof(CapacityControlSideScreen).FullName);`
/// `typeof(TargetedSideScreen).FullName` is the suggested value of this parameter.
/// Side screens from other mods can be used with their qualified names, even if no
/// reference to their type is available, but the target mod must have added their
/// custom side screen to the list first.</param>
/// <param name="insertBefore">Whether to insert the new screen before or after the
/// target side screen in the list. Defaults to before (true).
/// When inserting before the screen, if both are valid for a building then the side
/// screen of type "T" will show below the one of type "fullName". When inserting after
/// the screen, the reverse is true.</param>
/// <param name="uiPrefab">The UI prefab to use. If null is passed, the UI should
/// be created and added to the GameObject hosting the controller object in its
/// constructor.</param>
public static void AddSideScreenContentWithOrdering<T>(string targetClassName,
bool insertBefore = true, GameObject uiPrefab = null)
where T : SideScreenContent {
var inst = DetailsScreen.Instance;
if (inst == null)
LogUIWarning("DetailsScreen is not yet initialized, try a postfix on DetailsScreen.OnPrefabInit");
else {
var screens = UIDetours.SIDE_SCREENS.Get(inst);
var body = UIDetours.SS_CONTENT_BODY.Get(inst);
string name = typeof(T).Name;
if (body != null && screens != null) {
// The ref normally contains a prefab which is instantiated
var newScreen = new DetailsScreen.SideScreenRef();
// Mimic the basic screens
var rootObject = PUIElements.CreateUI(body, name);
// Preserve the border by fitting the child
rootObject.AddComponent<BoxLayoutGroup>().Params = new BoxLayoutParams() {
Direction = PanelDirection.Vertical, Alignment = TextAnchor.
UpperCenter, Margin = new RectOffset(1, 1, 0, 1)
};
var controller = rootObject.AddComponent<T>();
if (uiPrefab != null) {
// Add prefab if supplied
UIDetours.SS_CONTENT_CONTAINER.Set(controller, uiPrefab);
uiPrefab.transform.SetParent(rootObject.transform);
}
newScreen.name = name;
// Offset is never used
UIDetours.SS_OFFSET.Set(newScreen, Vector2.zero);
UIDetours.SS_PREFAB.Set(newScreen, controller);
UIDetours.SS_INSTANCE.Set(newScreen, controller);
InsertSideScreenContent(screens, newScreen, targetClassName, insertBefore);
}
}
}
/// <summary>
/// Builds a PLib UI object and adds it to an existing UI object.
/// </summary>
/// <param name="component">The UI object to add.</param>
/// <param name="parent">The parent of the new object.</param>
/// <param name="index">The sibling index to insert the element at, if provided.</param>
/// <returns>The built version of the UI object.</returns>
public static GameObject AddTo(this IUIComponent component, GameObject parent,
int index = -2) {
if (component == null)
throw new ArgumentNullException(nameof(component));
if (parent == null)
throw new ArgumentNullException(nameof(parent));
var child = component.Build();
child.SetParent(parent);
if (index == -1)
child.transform.SetAsLastSibling();
else if (index >= 0)
child.transform.SetSiblingIndex(index);
return child;
}
/// <summary>
/// Calculates the size of a single game object.
/// </summary>
/// <param name="obj">The object to calculate.</param>
/// <param name="direction">The direction to calculate.</param>
/// <param name="components">The components of this game object.</param>
/// <returns>The object's minimum and preferred size.</returns>
internal static LayoutSizes CalcSizes(GameObject obj, PanelDirection direction,
IEnumerable<Component> components) {
float min = 0.0f, preferred = 0.0f, flexible = 0.0f, scaleFactor;
int minPri = int.MinValue, prefPri = int.MinValue, flexPri = int.MinValue;
var scale = obj.transform.localScale;
// Find the correct scale direction
if (direction == PanelDirection.Horizontal)
scaleFactor = Math.Abs(scale.x);
else
scaleFactor = Math.Abs(scale.y);
bool ignore = false;
foreach (var component in components) {
if ((component as ILayoutIgnorer)?.ignoreLayout == true) {
ignore = true;
break;
}
if ((component as Behaviour)?.isActiveAndEnabled != false && component is
ILayoutElement le) {
int lp = le.layoutPriority;
// Calculate must come first
if (direction == PanelDirection.Horizontal) {
le.CalculateLayoutInputHorizontal();
PriValue(ref min, le.minWidth, lp, ref minPri);
PriValue(ref preferred, le.preferredWidth, lp, ref prefPri);
PriValue(ref flexible, le.flexibleWidth, lp, ref flexPri);
} else { // if (direction == PanelDirection.Vertical)
le.CalculateLayoutInputVertical();
PriValue(ref min, le.minHeight, lp, ref minPri);
PriValue(ref preferred, le.preferredHeight, lp, ref prefPri);
PriValue(ref flexible, le.flexibleHeight, lp, ref flexPri);
}
}
}
return new LayoutSizes(obj, min * scaleFactor, Math.Max(min, preferred) *
scaleFactor, flexible) { ignore = ignore };
}
/// <summary>
/// Dumps information about the parent tree of the specified GameObject to the debug
/// log.
/// </summary>
/// <param name="item">The item to determine hierarchy.</param>
public static void DebugObjectHierarchy(this GameObject item) {
string info = "null";
if (item != null) {
var result = new StringBuilder(256);
do {
result.Append("- ");
result.Append(item.name ?? "Unnamed");
item = item.transform?.parent?.gameObject;
if (item != null)
result.AppendLine();
} while (item != null);
info = result.ToString();
}
LogUIDebug("Object Tree:" + Environment.NewLine + info);
}
/// <summary>
/// Dumps information about the specified GameObject to the debug log.
/// </summary>
/// <param name="root">The root hierarchy to dump.</param>
public static void DebugObjectTree(this GameObject root) {
string info = "null";
if (root != null)
info = GetObjectTree(root, 0);
LogUIDebug("Object Dump:" + Environment.NewLine + info);
}
/// <summary>
/// Derives a font style from an existing style. The font face is copied unchanged,
/// but the other settings can be optionally modified.
/// </summary>
/// <param name="root">The style to use as a template.</param>
/// <param name="size">The font size, or 0 to use the template size.</param>
/// <param name="newColor">The font color, or null to use the template color.</param>
/// <param name="style">The font style, or null to use the template style.</param>
/// <returns>A copy of the root style with the specified parameters altered.</returns>
public static TextStyleSetting DeriveStyle(this TextStyleSetting root, int size = 0,
Color? newColor = null, FontStyles? style = null) {
if (root == null)
throw new ArgumentNullException(nameof(root));
var newStyle = ScriptableObject.CreateInstance<TextStyleSetting>();
newStyle.enableWordWrapping = root.enableWordWrapping;
newStyle.style = (style == null) ? root.style : (FontStyles)style;
newStyle.fontSize = (size > 0) ? size : root.fontSize;
newStyle.sdfFont = root.sdfFont;
newStyle.textColor = (newColor == null) ? root.textColor : (Color)newColor;
return newStyle;
}
/// <summary>
/// A debug function used to forcefully re-layout a UI.
/// </summary>
/// <param name="uiElement">The UI to layout</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject ForceLayoutRebuild(GameObject uiElement) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
var rt = uiElement.rectTransform();
if (rt != null)
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);
return uiElement;
}
/// <summary>
/// Retrieves the estimated width of a single string character (uses 'm' as the
/// standard estimation character) in the given text style.
/// </summary>
/// <param name="style">The text style to use.</param>
/// <returns>The width in pixels that should be allocated.</returns>
public static float GetEmWidth(TextStyleSetting style) {
float width = 0.0f;
if (style == null)
throw new ArgumentNullException(nameof(style));
var font = style.sdfFont;
// Use the em width
if (font != null && font.characterDictionary.TryGetValue('m', out TMP_Glyph em)) {
var info = font.fontInfo;
float ptSize = style.fontSize / (info.PointSize * info.Scale);
width = em.width * ptSize + style.fontSize * 0.01f * font.normalSpacingOffset;
}
return width;
}
/// <summary>
/// Retrieves the estimated height of one line of text in the given text style.
/// </summary>
/// <param name="style">The text style to use.</param>
/// <returns>The height in pixels that should be allocated.</returns>
public static float GetLineHeight(TextStyleSetting style) {
float height = 0.0f;
if (style == null)
throw new ArgumentNullException(nameof(style));
var font = style.sdfFont;
if (font != null) {
var info = font.fontInfo;
height = info.LineHeight * style.fontSize / (info.Scale * info.PointSize);
}
return height;
}
/// <summary>
/// Creates a string recursively describing the specified GameObject.
/// </summary>
/// <param name="root">The root GameObject hierarchy.</param>
/// <param name="indent">The indentation to use.</param>
/// <returns>A string describing this game object.</returns>
private static string GetObjectTree(GameObject root, int indent) {
var result = new StringBuilder(1024);
// Calculate indent to make nested reading easier
var solBuilder = new StringBuilder(indent);
for (int i = 0; i < indent; i++)
solBuilder.Append(' ');
string sol = solBuilder.ToString();
var transform = root.transform;
int n = transform.childCount;
// Basic information
result.Append(sol).AppendFormat("GameObject[{0}, {1:D} child(ren), Layer {2:D}, " +
"Active={3}]", root.name, n, root.layer, root.activeInHierarchy).AppendLine();
// Transformation
result.Append(sol).AppendFormat(" Translation={0} [{3}] Rotation={1} [{4}] " +
"Scale={2}", transform.position, transform.rotation, transform.
localScale, transform.localPosition, transform.localRotation).AppendLine();
// Components
foreach (var component in root.GetComponents<Component>()) {
if (component is RectTransform rt) {
// UI rectangle
Vector2 size = rt.rect.size, aMin = rt.anchorMin, aMax = rt.anchorMax,
oMin = rt.offsetMin, oMax = rt.offsetMax, pivot = rt.pivot;
result.Append(sol).AppendFormat(" Rect[Size=({0:F2},{1:F2}) Min=" +
"({2:F2},{3:F2}) ", size.x, size.y, LayoutUtility.GetMinWidth(rt),
LayoutUtility.GetMinHeight(rt));
result.AppendFormat("Preferred=({0:F2},{1:F2}) Flexible=({2:F2}," +
"{3:F2}) ", LayoutUtility.GetPreferredWidth(rt), LayoutUtility.
GetPreferredHeight(rt), LayoutUtility.GetFlexibleWidth(rt),
LayoutUtility.GetFlexibleHeight(rt));
result.AppendFormat("Pivot=({4:F2},{5:F2}) AnchorMin=({0:F2},{1:F2}) " +
"AnchorMax=({2:F2},{3:F2}) ", aMin.x, aMin.y, aMax.x, aMax.y, pivot.x,
pivot.y);
result.AppendFormat("OffsetMin=({0:F2},{1:F2}) OffsetMax=({2:F2}," +
"{3:F2})]", oMin.x, oMin.y, oMax.x, oMax.y).AppendLine();
} else if (component != null && !(component is Transform)) {
// Exclude destroyed components and Transform objects
result.Append(sol).Append(" Component[").Append(component.GetType().
FullName);
AddComponentText(result, component);
result.AppendLine("]");
}
}
// Children
if (n > 0)
result.Append(sol).AppendLine(" Children:");
for (int i = 0; i < n; i++) {
var child = transform.GetChild(i).gameObject;
if (child != null)
// Exclude destroyed objects
result.AppendLine(GetObjectTree(child, indent + 2));
}
return result.ToString().TrimEnd();
}
/// <summary>
/// Determines the size for a component on a particular axis.
/// </summary>
/// <param name="sizes">The declared sizes.</param>
/// <param name="allocated">The space allocated.</param>
/// <returns>The size that the component should be.</returns>
internal static float GetProperSize(LayoutSizes sizes, float allocated) {
float size = sizes.min, preferred = Math.Max(sizes.preferred, size);
// Compute size: minimum guaranteed, then preferred, then flexible
if (allocated > size)
size = Math.Min(preferred, allocated);
if (allocated > preferred && sizes.flexible > 0.0f)
size = allocated;
return size;
}
/// <summary>
/// Gets the offset required for a component in its box.
/// </summary>
/// <param name="alignment">The alignment to use.</param>
/// <param name="direction">The direction of layout.</param>
/// <param name="delta">The remaining space.</param>
/// <returns>The offset from the edge.</returns>
internal static float GetOffset(TextAnchor alignment, PanelDirection direction,
float delta) {
float offset = 0.0f;
// Based on alignment, offset component
if (direction == PanelDirection.Horizontal)
switch (alignment) {
case TextAnchor.LowerCenter:
case TextAnchor.MiddleCenter:
case TextAnchor.UpperCenter:
offset = delta * 0.5f;
break;
case TextAnchor.LowerRight:
case TextAnchor.MiddleRight:
case TextAnchor.UpperRight:
offset = delta;
break;
default:
break;
} else
switch (alignment) {
case TextAnchor.MiddleLeft:
case TextAnchor.MiddleCenter:
case TextAnchor.MiddleRight:
offset = delta * 0.5f;
break;
case TextAnchor.LowerLeft:
case TextAnchor.LowerCenter:
case TextAnchor.LowerRight:
offset = delta;
break;
default:
break;
}
return offset;
}
/// <summary>
/// Retrieves the parent of the GameObject, or null if it does not have a parent.
/// </summary>
/// <param name="child">The child object.</param>
/// <returns>The parent of that object, or null if it does not have a parent.</returns>
public static GameObject GetParent(this GameObject child) {
GameObject parent = null;
if (child != null) {
var newParent = child.transform.parent?.gameObject;
// If parent is disposed, prevent crash
if (newParent != null)
parent = newParent;
}
return parent;
}
/// <summary>
/// Insets a child component from its parent, and assigns a fixed size to the parent
/// equal to the provided size plus the insets.
/// </summary>
/// <param name="parent">The parent component.</param>
/// <param name="child">The child to inset.</param>
/// <param name="vertical">The vertical inset on each side.</param>
/// <param name="horizontal">The horizontal inset on each side.</param>
/// <param name="prefSize">The minimum component size.</param>
/// <returns>The parent component.</returns>
internal static GameObject InsetChild(GameObject parent, GameObject child,
Vector2 insets, Vector2 prefSize = default(Vector2)) {
var rt = child.rectTransform();
float horizontal = insets.x, vertical = insets.y, width = prefSize.x, height =
prefSize.y;
rt.offsetMax = new Vector2(-horizontal, -vertical);
rt.offsetMin = insets;
var layout = parent.AddOrGet<LayoutElement>();
layout.minWidth = layout.preferredWidth = (width <= 0.0f ? LayoutUtility.
GetPreferredWidth(rt) : width) + horizontal * 2.0f;
layout.minHeight = layout.preferredHeight = (height <= 0.0f ? LayoutUtility.
GetPreferredHeight(rt) : height) + vertical * 2.0f;
return parent;
}
/// <summary>
/// Inserts the side screen at the target location.
/// </summary>
/// <param name="screens">The current list of side screens.</param>
/// <param name="newScreen">The screen to insert.</param>
/// <param name="targetClassName">The target class name for locating the screen. If this
/// class is not found, it will be added at the end regardless of insertBefore.</param>
/// <param name="insertBefore">true to insert before that class, or false to insert after.</param>
private static void InsertSideScreenContent(IList<SideScreenRef> screens,
SideScreenRef newScreen, string targetClassName, bool insertBefore) {
if (screens == null)
throw new ArgumentNullException(nameof(screens));
if (newScreen == null)
throw new ArgumentNullException(nameof(newScreen));
if (string.IsNullOrEmpty(targetClassName))
// Add to end by default
screens.Add(newScreen);
else {
int n = screens.Count;
bool found = false;
for (int i = 0; i < n; i++) {
var screen = screens[i];
var sideScreenPrefab = UIDetours.SS_PREFAB.Get(screen);
if (sideScreenPrefab != null) {
var contents = sideScreenPrefab.
GetComponentsInChildren<SideScreenContent>();
if (contents == null || contents.Length < 1)
// Some naughty mod added a prefab with no side screen content!
LogUIWarning("Could not find SideScreenContent on side screen: " +
screen.name);
else if (contents[0].GetType().FullName == targetClassName) {
// Once the first matching screen is found, perform insertion
if (insertBefore)
screens.Insert(i, newScreen);
else if (i >= n - 1)
screens.Add(newScreen);
else
screens.Insert(i + 1, newScreen);
found = true;
break;
}
}
}
// Warn if no match found
if (!found) {
LogUIWarning("No side screen with class name {0} found!".F(
targetClassName));
screens.Add(newScreen);
}
}
}
/// <summary>
/// Loads a sprite embedded in the calling assembly.
///
/// It may be encoded using PNG, DXT5, or JPG format.
/// </summary>
/// <param name="path">The fully qualified path to the image to load.</param>
/// <param name="border">The sprite border. If there is no 9-patch border, use default(Vector4).</param>
/// <param name="log">true to log the sprite load, or false to load silently.</param>
/// <returns>The sprite thus loaded.</returns>
/// <exception cref="ArgumentException">If the image could not be loaded.</exception>
public static Sprite LoadSprite(string path, Vector4 border = default, bool log = true)
{
return LoadSprite(Assembly.GetCallingAssembly() ?? Assembly.GetExecutingAssembly(),
path, border, log);
}
/// <summary>
/// Loads a sprite embedded in the specified assembly as a 9-slice sprite.
///
/// It may be encoded using PNG, DXT5, or JPG format.
/// </summary>
/// <param name="assembly">The assembly containing the image.</param>
/// <param name="path">The fully qualified path to the image to load.</param>
/// <param name="border">The sprite border.</param>
/// <param name="log">true to log the load, or false otherwise.</param>
/// <returns>The sprite thus loaded.</returns>
/// <exception cref="ArgumentException">If the image could not be loaded.</exception>
internal static Sprite LoadSprite(Assembly assembly, string path, Vector4 border =
default, bool log = false) {
// Open a stream to the image
try {
using (var stream = assembly.GetManifestResourceStream(path)) {
if (stream == null)
throw new ArgumentException("Could not load image: " + path);
// If len > int.MaxValue we will not go to space today
int len = (int)stream.Length;
byte[] buffer = new byte[len];
var texture = new Texture2D(2, 2);
// Load the texture from the stream
stream.Read(buffer, 0, len);
ImageConversion.LoadImage(texture, buffer, false);
// Create a sprite centered on the texture
int width = texture.width, height = texture.height;
#if DEBUG
log = true;
#endif
if (log)
LogUIDebug("Loaded sprite: {0} ({1:D}x{2:D}, {3:D} bytes)".F(path,
width, height, len));
// pivot is in RELATIVE coordinates!
return Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(
0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border);
}
} catch (IOException e) {
throw new ArgumentException("Could not load image: " + path, e);
}
}
/// <summary>
/// Loads a sprite from the file system as a 9-slice sprite.
///
/// It may be encoded using PNG, DXT5, or JPG format.
/// </summary>
/// <param name="path">The path to the image to load.</param>
/// <param name="border">The sprite border.</param>
/// <param name="log">true to log the load, or false otherwise.</param>
/// <returns>The sprite thus loaded, or null if it could not be loaded.</returns>
public static Sprite LoadSpriteFile(string path, Vector4 border = default) {
Sprite sprite = null;
// Open a stream to the image
try {
using (var stream = new FileStream(path, FileMode.Open)) {
// If len > int.MaxValue we will not go to space today
int len = (int)stream.Length;
byte[] buffer = new byte[len];
var texture = new Texture2D(2, 2);
// Load the texture from the stream
stream.Read(buffer, 0, len);
ImageConversion.LoadImage(texture, buffer, false);
// Create a sprite centered on the texture
int width = texture.width, height = texture.height;
sprite = Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(
0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border);
}
} catch (IOException e) {
#if DEBUG
PUtil.LogExcWarn(e);
#endif
}
return sprite;
}
/// <summary>
/// Loads a DDS sprite embedded in the specified assembly as a 9-slice sprite.
///
/// It must be encoded using the DXT5 format.
/// </summary>
/// <param name="assembly">The assembly containing the image.</param>
/// <param name="path">The fully qualified path to the DDS image to load.</param>
/// <param name="width">The desired width.</param>
/// <param name="height">The desired height.</param>
/// <param name="border">The sprite border.</param>
/// <param name="log">true to log the load, or false otherwise.</param>
/// <returns>The sprite thus loaded.</returns>
/// <exception cref="ArgumentException">If the image could not be loaded.</exception>
internal static Sprite LoadSpriteLegacy(Assembly assembly, string path, int width,
int height, Vector4 border = default) {
// Open a stream to the image
try {
using (var stream = assembly.GetManifestResourceStream(path)) {
const int SKIP = 128;
if (stream == null)
throw new ArgumentException("Could not load image: " + path);
// If len > int.MaxValue we will not go to space today, skip first 128
// bytes of stream
int len = (int)stream.Length - SKIP;
if (len < 0)
throw new ArgumentException("Image is too small: " + path);
byte[] buffer = new byte[len];
stream.Seek(SKIP, SeekOrigin.Begin);
stream.Read(buffer, 0, len);
// Load the texture from the stream
var texture = new Texture2D(width, height, TextureFormat.DXT5, false);
texture.LoadRawTextureData(buffer);
texture.Apply(true, true);
// Create a sprite centered on the texture
LogUIDebug("Loaded sprite: {0} ({1:D}x{2:D}, {3:D} bytes)".F(path,
width, height, len));
// pivot is in RELATIVE coordinates!
return Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(
0.5f, 0.5f), 100.0f, 0, SpriteMeshType.FullRect, border);
}
} catch (IOException e) {
throw new ArgumentException("Could not load image: " + path, e);
}
}
/// <summary>
/// Logs a debug message encountered in PLib UI functions.
/// </summary>
/// <param name="message">The debug message.</param>
internal static void LogUIDebug(string message) {
Debug.LogFormat("[PLib/UI/{0}] {1}", Assembly.GetCallingAssembly()?.GetName()?.
Name ?? "?", message);
}
/// <summary>
/// Logs a warning encountered in PLib UI functions.
/// </summary>
/// <param name="message">The warning message.</param>
internal static void LogUIWarning(string message) {
Debug.LogWarningFormat("[PLib/UI/{0}] {1}", Assembly.GetCallingAssembly()?.
GetName()?.Name ?? "?", message);
}
/// <summary>
/// Aggregates layout values, replacing the value if a higher priority value is given
/// and otherwise taking the largest value.
/// </summary>
/// <param name="value">The current value.</param>
/// <param name="newValue">The candidate new value. No operation if this is less than zero.</param>
/// <param name="newPri">The new value's layout priority.</param>
/// <param name="pri">The current value's priority</param>
private static void PriValue(ref float value, float newValue, int newPri, ref int pri)
{
int thisPri = pri;
if (newValue >= 0.0f) {
if (newPri > thisPri) {
// Priority override?
pri = newPri;
value = newValue;
} else if (newValue > value && newPri == thisPri)
// Same priority and higher value?
value = newValue;
}
}
/// <summary>
/// Sets a UI element's flexible size.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="flexSize">The flexible size as a ratio.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetFlexUISize(this GameObject uiElement, Vector2 flexSize) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
if (uiElement.TryGetComponent(out ISettableFlexSize fs)) {
// Avoid duplicate LayoutElement on layouts
fs.flexibleWidth = flexSize.x;
fs.flexibleHeight = flexSize.y;
} else {
var le = uiElement.AddOrGet<LayoutElement>();
le.flexibleWidth = flexSize.x;
le.flexibleHeight = flexSize.y;
}
return uiElement;
}
/// <summary>
/// Sets a UI element's minimum size.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="minSize">The minimum size in units.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetMinUISize(this GameObject uiElement, Vector2 minSize) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
float minX = minSize.x, minY = minSize.y;
if (minX > 0.0f || minY > 0.0f) {
var le = uiElement.AddOrGet<LayoutElement>();
if (minX > 0.0f)
le.minWidth = minX;
if (minY > 0.0f)
le.minHeight = minY;
}
return uiElement;
}
/// <summary>
/// Immediately resizes a UI element. Uses the element's current anchors. If a
/// dimension of the size is negative, the component will not be resized in that
/// dimension.
///
/// If addLayout is true, a layout element is also added so that future auto layout
/// calls will try to maintain that size. Do not set addLayout to true if either of
/// the size dimensions are negative, as laying out components with a negative
/// preferred size may cause unexpected behavior.
/// </summary>
/// <param name="uiElement">The UI element to modify.</param>
/// <param name="size">The new element size.</param>
/// <param name="addLayout">true to add a layout element with that size, or false
/// otherwise.</param>
/// <returns>The UI element, for call chaining.</returns>
public static GameObject SetUISize(this GameObject uiElement, Vector2 size,
bool addLayout = false) {
if (uiElement == null)
throw new ArgumentNullException(nameof(uiElement));
var transform = uiElement.rectTransform();
float width = size.x, height = size.y;
if (transform != null) {
if (width >= 0.0f)
transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
if (height >= 0.0f)
transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
}
if (addLayout) {
var le = uiElement.AddOrGet<LayoutElement>();
// Set minimum and preferred size
le.minWidth = width;
le.minHeight = height;
le.preferredWidth = width;
le.preferredHeight = height;
le.flexibleHeight = 0.0f;
le.flexibleWidth = 0.0f;
}
return uiElement;
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using UnityEngine;
namespace PeterHan.PLib.UI {
/// <summary>
/// Extension methods to deal with TextAnchor alignments.
/// </summary>
public static class TextAnchorUtils {
/// <summary>
/// Returns true if this text alignment is at the left.
/// </summary>
/// <param name="anchor">The anchoring to check.</param>
/// <returns>true if it denotes a Left alignment, or false otherwise.</returns>
public static bool IsLeft(this TextAnchor anchor) {
return anchor == TextAnchor.UpperLeft || anchor == TextAnchor.MiddleLeft ||
anchor == TextAnchor.LowerLeft;
}
/// <summary>
/// Returns true if this text alignment is at the bottom.
/// </summary>
/// <param name="anchor">The anchoring to check.</param>
/// <returns>true if it denotes a Lower alignment, or false otherwise.</returns>
public static bool IsLower(this TextAnchor anchor) {
return anchor == TextAnchor.LowerCenter || anchor == TextAnchor.LowerLeft ||
anchor == TextAnchor.LowerRight;
}
/// <summary>
/// Returns true if this text alignment is at the right.
/// </summary>
/// <param name="anchor">The anchoring to check.</param>
/// <returns>true if it denotes a Right alignment, or false otherwise.</returns>
public static bool IsRight(this TextAnchor anchor) {
return anchor == TextAnchor.UpperRight || anchor == TextAnchor.MiddleRight ||
anchor == TextAnchor.LowerRight;
}
/// <summary>
/// Returns true if this text alignment is at the top.
/// </summary>
/// <param name="anchor">The anchoring to check.</param>
/// <returns>true if it denotes an Upper alignment, or false otherwise.</returns>
public static bool IsUpper(this TextAnchor anchor) {
return anchor == TextAnchor.UpperCenter || anchor == TextAnchor.UpperLeft ||
anchor == TextAnchor.UpperRight;
}
/// <summary>
/// Mirrors a text alignment horizontally. UpperLeft becomes UpperRight, MiddleLeft
/// becomes MiddleRight, and so forth.
/// </summary>
/// <param name="anchor">The anchoring to mirror.</param>
/// <returns>The horizontally reflected version of that mirror.</returns>
public static TextAnchor MirrorHorizontal(this TextAnchor anchor) {
int newAnchor = (int)anchor;
// UL UC UR ML MC MR LL LC LR
// Danger will robinson!
newAnchor = 3 * (newAnchor / 3) + 2 - newAnchor % 3;
return (TextAnchor)newAnchor;
}
/// <summary>
/// Mirrors a text alignment vertically. UpperLeft becomes LowerLeft, LowerCenter
/// becomes UpperCenter, and so forth.
/// </summary>
/// <param name="anchor">The anchoring to mirror.</param>
/// <returns>The vertically reflected version of that mirror.</returns>
public static TextAnchor MirrorVertical(this TextAnchor anchor) {
int newAnchor = (int)anchor;
newAnchor = 6 - 3 * (newAnchor / 3) + newAnchor % 3;
return (TextAnchor)newAnchor;
}
}
}

97
mod/PLibUI/UIDetours.cs Normal file
View File

@@ -0,0 +1,97 @@
/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.Detours;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using SideScreenRef = DetailsScreen.SideScreenRef;
namespace PeterHan.PLib.UI {
/// <summary>
/// Stores detours used for Klei UI components. Klei loves adding optional parameters and
/// changing fields to/from properties, which while source compatible is binary
/// incompatible. These lazy detours (resolved on first use) can bridge over a variety of
/// such differences with minimal overhead and no recompilation.
/// </summary>
internal static class UIDetours {
#region ConfirmDialogScreen
public delegate void PCD(ConfirmDialogScreen dialog, string text,
System.Action on_confirm, System.Action on_cancel, string configurable_text,
System.Action on_configurable_clicked, string title_text, string confirm_text,
string cancel_text);
public static readonly DetouredMethod<PCD> POPUP_CONFIRM = typeof(ConfirmDialogScreen).DetourLazy<PCD>(nameof(ConfirmDialogScreen.PopupConfirmDialog));
#endregion
#region DetailsScreen
public static readonly IDetouredField<DetailsScreen, List<SideScreenRef>> SIDE_SCREENS = PDetours.DetourFieldLazy<DetailsScreen, List<SideScreenRef>>("sideScreens");
public static readonly IDetouredField<DetailsScreen, GameObject> SS_CONTENT_BODY = PDetours.DetourFieldLazy<DetailsScreen, GameObject>("sideScreenContentBody");
#endregion
#region KButton
public static readonly IDetouredField<KButton, KImage[]> ADDITIONAL_K_IMAGES = PDetours.DetourFieldLazy<KButton, KImage[]>(nameof(KButton.additionalKImages));
public static readonly IDetouredField<KButton, KImage> BG_IMAGE = PDetours.DetourFieldLazy<KButton, KImage>(nameof(KButton.bgImage));
public static readonly IDetouredField<KButton, Image> FG_IMAGE = PDetours.DetourFieldLazy<KButton, Image>(nameof(KButton.fgImage));
public static readonly IDetouredField<KButton, bool> IS_INTERACTABLE = PDetours.DetourFieldLazy<KButton, bool>(nameof(KButton.isInteractable));
public static readonly IDetouredField<KButton, ButtonSoundPlayer> SOUND_PLAYER_BUTTON = PDetours.DetourFieldLazy<KButton, ButtonSoundPlayer>(nameof(KButton.soundPlayer));
#endregion
#region KImage
public static readonly IDetouredField<KImage, ColorStyleSetting> COLOR_STYLE_SETTING = PDetours.DetourFieldLazy<KImage, ColorStyleSetting>(nameof(KImage.colorStyleSetting));
public static readonly DetouredMethod<Action<KImage>> APPLY_COLOR_STYLE = typeof(KImage).DetourLazy<Action<KImage>>(nameof(KImage.ApplyColorStyleSetting));
#endregion
#region KScreen
public static readonly DetouredMethod<Action<KScreen>> ACTIVATE_KSCREEN = typeof(KScreen).DetourLazy<Action<KScreen>>(nameof(KScreen.Activate));
public static readonly DetouredMethod<Action<KScreen>> DEACTIVATE_KSCREEN = typeof(KScreen).DetourLazy<Action<KScreen>>(nameof(KScreen.Deactivate));
#endregion
#region KToggle
public static readonly IDetouredField<KToggle, KToggleArtExtensions> ART_EXTENSION = PDetours.DetourFieldLazy<KToggle, KToggleArtExtensions>(nameof(KToggle.artExtension));
public static readonly IDetouredField<KToggle, bool> IS_ON = PDetours.DetourFieldLazy<KToggle, bool>(nameof(KToggle.isOn));
public static readonly IDetouredField<KToggle, ToggleSoundPlayer> SOUND_PLAYER_TOGGLE = PDetours.DetourFieldLazy<KToggle, ToggleSoundPlayer>(nameof(KToggle.soundPlayer));
#endregion
#region LocText
public static readonly IDetouredField<LocText, string> LOCTEXT_KEY = PDetours.DetourFieldLazy<LocText, string>(nameof(LocText.key));
public static readonly IDetouredField<LocText, TextStyleSetting> LOCTEXT_STYLE = PDetours.DetourFieldLazy<LocText, TextStyleSetting>(nameof(LocText.textStyleSetting));
#endregion
#region MultiToggle
public static readonly IDetouredField<MultiToggle, int> CURRENT_STATE = PDetours.DetourFieldLazy<MultiToggle, int>(nameof(MultiToggle.CurrentState));
public static readonly IDetouredField<MultiToggle, bool> PLAY_SOUND_CLICK = PDetours.DetourFieldLazy<MultiToggle, bool>(nameof(MultiToggle.play_sound_on_click));
public static readonly IDetouredField<MultiToggle, bool> PLAY_SOUND_RELEASE = PDetours.DetourFieldLazy<MultiToggle, bool>(nameof(MultiToggle.play_sound_on_release));
public static readonly DetouredMethod<Action<MultiToggle, int>> CHANGE_STATE = typeof(MultiToggle).DetourLazy<Action<MultiToggle, int>>(nameof(MultiToggle.ChangeState));
#endregion
#region SideScreenContent
public static readonly IDetouredField<SideScreenContent, GameObject> SS_CONTENT_CONTAINER = PDetours.DetourFieldLazy<SideScreenContent, GameObject>(nameof(SideScreenContent.ContentContainer));
#endregion
#region SideScreenRef
public static readonly IDetouredField<SideScreenRef, Vector2> SS_OFFSET = PDetours.DetourFieldLazy<SideScreenRef, Vector2>(nameof(SideScreenRef.offset));
public static readonly IDetouredField<SideScreenRef, SideScreenContent> SS_PREFAB = PDetours.DetourFieldLazy<SideScreenRef, SideScreenContent>(nameof(SideScreenRef.screenPrefab));
public static readonly IDetouredField<SideScreenRef, SideScreenContent> SS_INSTANCE = PDetours.DetourFieldLazy<SideScreenRef, SideScreenContent>(nameof(SideScreenRef.screenInstance));
#endregion
}
}