From 2a6a9c8b6a05b25b8c005c4acb9f0a7fa66d5868 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 10:47:24 +0300 Subject: [PATCH 01/30] feature: edge capacity --- UnityPackage/Runtime/Core/EdgeSource.cs | 19 ++++- UnityPackage/Runtime/Core/Shape.cs | 37 +++++++++- UnityPackage/Tests/Runtime/VisibilityTests.cs | 74 ++++++++++++++++++- 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/UnityPackage/Runtime/Core/EdgeSource.cs b/UnityPackage/Runtime/Core/EdgeSource.cs index c5cf830..1609346 100644 --- a/UnityPackage/Runtime/Core/EdgeSource.cs +++ b/UnityPackage/Runtime/Core/EdgeSource.cs @@ -7,28 +7,37 @@ namespace reromanlee.Wireframes /// internal readonly struct EdgeSource { + private const int WholePattern = -1; + private readonly int[] _pattern; private readonly PatternCache _cache; private readonly int _resolution; + private readonly int _edgeCount; internal EdgeSource(PatternCache cache, int resolution) { _pattern = null; _cache = cache; _resolution = resolution; + _edgeCount = WholePattern; } - private EdgeSource(int[] pattern) + /// + /// The first pairs of a pattern owned by one shape, which keeps room after them for + /// edits that add edges, as a text does when its glyphs change. + /// + internal EdgeSource(int[] pattern, int edgeCount) { _pattern = pattern; _cache = null; _resolution = 0; + _edgeCount = edgeCount; } /// A pattern owned by one shape: pairs of local vertex indices, one pair per edge. public static implicit operator EdgeSource(int[] pattern) { - return new EdgeSource(pattern); + return new EdgeSource(pattern, WholePattern); } internal int[] Acquire() @@ -40,5 +49,11 @@ internal void Release() { _cache?.Release(_resolution); } + + /// Number of edges the shape draws from , the array gave. + internal int EdgeCountOf(int[] pattern) + { + return _edgeCount == WholePattern ? pattern.Length / 2 : _edgeCount; + } } } diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index 7330d9b..5a10572 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -13,6 +13,7 @@ internal abstract class Shape : IShape, IEdgeOwner private EdgeSource _edgeSource; private int[] _edgePattern; private int[] _edgeSlots; + private int _edgeCount; private IShapeHost _host; private DirtyFlags _dirty; private bool _isHidden; @@ -91,7 +92,7 @@ internal bool IsHidden internal int EdgeCount { - get => _edgeSlots.Length; + get => _edgeCount; } /// True once a problem with the shape was logged, so each shape logs at most one. @@ -133,6 +134,7 @@ public void Dispose() internal void Attach(MeshProxy proxy) { _edgePattern = _edgeSource.Acquire(); + _edgeCount = _edgeSource.EdgeCountOf(_edgePattern); _edgeSlots = new int[_edgePattern.Length / 2]; BoneRegistry bones = proxy.Bones; AcquireBones(bones); @@ -200,6 +202,7 @@ internal void Resize(int vertexCount, EdgeSource edgeSource) VertexCount = vertexCount; _edgeSource = edgeSource; _edgePattern = edgeSource.Acquire(); + _edgeCount = edgeSource.EdgeCountOf(_edgePattern); if (_edgeSlots.Length != _edgePattern.Length / 2) { _edgeSlots = new int[_edgePattern.Length / 2]; @@ -220,6 +223,36 @@ internal void Resize(int vertexCount, EdgeSource edgeSource) MarkDirty(DirtyFlags.All); } + /// + /// Replaces the edges of a shape that owns its pattern while its vertices stay where they are, as a text does when + /// its glyphs change. Only the first pairs of are drawn, + /// so the array can be the current one rewritten in place, with room to spare. The edges are written again on the + /// next flush. If they can't be added, the shape ends up hidden and the exception is rethrown. + /// + internal void ReplaceEdges(int[] pattern, int edgeCount) + { + bool isDrawn = _host != null && !_isHidden; + if (isDrawn) + { + // Hiding takes the old edges out of the drawn ones, and showing adds the new ones. + _host.Hide(this); + _isHidden = true; + } + _edgeSource.Release(); + _edgeSource = new EdgeSource(pattern, edgeCount); + _edgePattern = pattern; + _edgeCount = edgeCount; + if (_edgeSlots.Length < pattern.Length / 2) + { + _edgeSlots = new int[pattern.Length / 2]; + } + if (isDrawn) + { + _host.Show(this); + _isHidden = false; + } + } + internal int GetEdgeSlot(int edge) { return _edgeSlots[edge]; @@ -261,7 +294,7 @@ internal void Detach() internal void WriteEdges(EdgeList edges, DirtyRanges ranges) { - for (int i = 0; i < _edgeSlots.Length; i++) + for (int i = 0; i < _edgeCount; i++) { int slot = _edgeSlots[i]; edges.Set(slot, VertexStart + _edgePattern[i * 2], VertexStart + _edgePattern[i * 2 + 1]); diff --git a/UnityPackage/Tests/Runtime/VisibilityTests.cs b/UnityPackage/Tests/Runtime/VisibilityTests.cs index a052dab..4b60f24 100644 --- a/UnityPackage/Tests/Runtime/VisibilityTests.cs +++ b/UnityPackage/Tests/Runtime/VisibilityTests.cs @@ -6,7 +6,7 @@ namespace reromanlee.Wireframes.Tests { - /// Hiding shapes and containers without disposing them, and shapes that change their vertex count. + /// Hiding shapes and containers without disposing them, and shapes that change their vertex or edge count. public class VisibilityTests : WireframesTestBase { [Test] @@ -173,6 +173,78 @@ public void ResizedHiddenShape_StaysHiddenUntilShown() AssertChain(container, chain, 6, 1f); } + [Test] + public void ReplacedEdges_AreDrawnFromTheSameVertices() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(4, 0f))); + ILine after = container.CreateLine(Vector3.one, Vector3.one * 2f); + container.Proxy.Flush(); + int vertexStart = chain.VertexStart; + + // Room for 4 edges, of which the first 2 join every other point. + chain.ReplaceEdges(new[] { 0, 2, 1, 3, 0, 0, 0, 0 }, 2); + + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, chain); + Assert.That(chain.VertexStart, Is.EqualTo(vertexStart)); + Assert.That(edges, Has.Length.EqualTo(2)); + AssertApproximately(new Vector3(0f, 0f, 0f), edges[0].A); + AssertApproximately(new Vector3(2f, 0f, 0f), edges[0].B); + AssertApproximately(new Vector3(1f, 0f, 0f), edges[1].A); + AssertApproximately(new Vector3(3f, 0f, 0f), edges[1].B); + Assert.That(ChunkOf(chain).Mesh.GetIndices(0), Has.Length.EqualTo((2 + 1) * 2)); + AssertApproximately(Vector3.one * 2f, BakeEdges(container, after)[0].B); + } + + [Test] + public void EdgesReplacedWhileHidden_AreDrawnOnceShown() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(4, 0f))); + chain.IsVisible = false; + + chain.ReplaceEdges(new[] { 0, 3 }, 1); + container.Proxy.Flush(); + Assert.That(ChunkOf(chain).Mesh.GetIndices(0), Is.Empty); + + chain.IsVisible = true; + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, chain); + Assert.That(edges, Has.Length.EqualTo(1)); + AssertApproximately(Vector3.zero, edges[0].A); + AssertApproximately(new Vector3(3f, 0f, 0f), edges[0].B); + } + + [Test] + public void ReplacingEdgesWithinTheirRoom_AllocatesNothing() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(8, 0f))); + int[] pattern = new int[7 * 2]; + + void Replace(int edgeCount) + { + for (int edge = 0; edge < edgeCount; edge++) + { + pattern[edge * 2] = edge; + pattern[edge * 2 + 1] = edge + 1; + } + chain.ReplaceEdges(pattern, edgeCount); + container.Proxy.Flush(); + } + + void ReplaceBackAndForth() + { + Replace(7); + Replace(2); + } + + // Warms up every path, and lets each list grow once to what the edits need between flushes. + ReplaceBackAndForth(); + ReplaceBackAndForth(); + + Assert.That(ReplaceBackAndForth, Is.Not.AllocatingGCMemory()); + } + private static Vector3[] Points(int count, float height) { Vector3[] points = new Vector3[count]; From 6165e2e38e5507fc53680779a649188fb6c05617 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 10:55:24 +0300 Subject: [PATCH 02/30] feature: glyph packs --- UnityPackage/Runtime/Glyphs.meta | 8 + UnityPackage/Runtime/Glyphs/CharacterGlyph.cs | 34 +++ .../Runtime/Glyphs/CharacterGlyph.cs.meta | 2 + UnityPackage/Runtime/Glyphs/GlyphEdits.cs | 39 ++++ .../Runtime/Glyphs/GlyphEdits.cs.meta | 2 + .../Runtime/Glyphs/GlyphEnumSettings.cs | 47 ++++ .../Runtime/Glyphs/GlyphEnumSettings.cs.meta | 2 + UnityPackage/Runtime/Glyphs/GlyphMetrics.cs | 68 ++++++ .../Runtime/Glyphs/GlyphMetrics.cs.meta | 2 + UnityPackage/Runtime/Glyphs/GlyphStroke.cs | 67 ++++++ .../Runtime/Glyphs/GlyphStroke.cs.meta | 2 + UnityPackage/Runtime/Glyphs/SymbolGlyph.cs | 37 ++++ .../Runtime/Glyphs/SymbolGlyph.cs.meta | 2 + UnityPackage/Runtime/Glyphs/SymbolKeys.cs | 93 ++++++++ .../Runtime/Glyphs/SymbolKeys.cs.meta | 2 + .../Runtime/Glyphs/WireframeGlyphPack.cs | 208 ++++++++++++++++++ .../Runtime/Glyphs/WireframeGlyphPack.cs.meta | 2 + UnityPackage/Tests/Runtime/SymbolKeysTests.cs | 64 ++++++ .../Tests/Runtime/SymbolKeysTests.cs.meta | 2 + 19 files changed, 683 insertions(+) create mode 100644 UnityPackage/Runtime/Glyphs.meta create mode 100644 UnityPackage/Runtime/Glyphs/CharacterGlyph.cs create mode 100644 UnityPackage/Runtime/Glyphs/CharacterGlyph.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphEdits.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphEdits.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphMetrics.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphMetrics.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphStroke.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphStroke.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/SymbolGlyph.cs create mode 100644 UnityPackage/Runtime/Glyphs/SymbolGlyph.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/SymbolKeys.cs create mode 100644 UnityPackage/Runtime/Glyphs/SymbolKeys.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs create mode 100644 UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs.meta create mode 100644 UnityPackage/Tests/Runtime/SymbolKeysTests.cs create mode 100644 UnityPackage/Tests/Runtime/SymbolKeysTests.cs.meta diff --git a/UnityPackage/Runtime/Glyphs.meta b/UnityPackage/Runtime/Glyphs.meta new file mode 100644 index 0000000..234f4b7 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9f8ed7763776e8b0ca61116699104669 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs b/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs new file mode 100644 index 0000000..2a7c900 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs @@ -0,0 +1,34 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// A glyph that draws a character of text, found by the character's Unicode code point. + [Serializable] + internal struct CharacterGlyph + { + internal const string CharacterField = nameof(_character); + internal const string StrokesField = nameof(_strokes); + + [SerializeField] private int _character; + [SerializeField] private GlyphStroke[] _strokes; + + internal CharacterGlyph(int character, GlyphStroke[] strokes) + { + _character = character; + _strokes = strokes; + } + + /// Unicode code point of the character, such as 65 for A. + internal int Character + { + get => _character; + } + + /// The glyph's lines; never null. + internal GlyphStroke[] Strokes + { + get => _strokes ?? Array.Empty(); + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs.meta b/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs.meta new file mode 100644 index 0000000..efa07b5 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/CharacterGlyph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0995156be4987898cf35464febca637c diff --git a/UnityPackage/Runtime/Glyphs/GlyphEdits.cs b/UnityPackage/Runtime/Glyphs/GlyphEdits.cs new file mode 100644 index 0000000..7288ef6 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphEdits.cs @@ -0,0 +1,39 @@ +using System.Threading; + +namespace reromanlee.Wireframes +{ + /// + /// Counts changes to glyph packs and glyph lists: edits, undo and reimports. Caches built from them compare + /// to know when they are out of date, so a lookup never has to check each pack it reads. + /// + internal static class GlyphEdits + { + private static int _version; + private static int _hasPendingChanges; + + /// Changes on every recorded edit; nothing changes it in builds once the assets are loaded. + internal static int Version + { + get => Volatile.Read(ref _version); + } + + /// + /// Records that glyph data changed. Safe from any thread, since Unity can deserialize assets off the main + /// thread while loading them. + /// + internal static void Record() + { + Interlocked.Increment(ref _version); + Volatile.Write(ref _hasPendingChanges, 1); + } + + /// + /// Returns whether anything was recorded since the last call, and clears it, for the editor's update loop that + /// redraws the shapes using the changed glyphs. + /// + internal static bool TakePendingChanges() + { + return Interlocked.Exchange(ref _hasPendingChanges, 0) != 0; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphEdits.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphEdits.cs.meta new file mode 100644 index 0000000..912ad40 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphEdits.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79838bf03da595031c732ae2c057eb61 diff --git a/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs b/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs new file mode 100644 index 0000000..5cb9418 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs @@ -0,0 +1,47 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// How the Glyph Editor generates a pack's symbol enum: its name and namespace, empty for the defaults, and the GUID + /// of the script it wrote last, so a moved script is found again. Only the Editor reads them. + /// + [Serializable] + internal struct GlyphEnumSettings + { + internal const string NameField = nameof(_name); + internal const string NamespaceField = nameof(_namespace); + internal const string ScriptGuidField = nameof(_scriptGuid); + + [Tooltip("Name of the generated enum. Empty uses the pack's name.")] + [SerializeField] private string _name; + + [Tooltip("Namespace of the generated enum. Empty uses the project's root namespace, if it has one.")] + [SerializeField] private string _namespace; + + [SerializeField] private string _scriptGuid; + + internal GlyphEnumSettings(string name, string namespaceName, string scriptGuid) + { + _name = name; + _namespace = namespaceName; + _scriptGuid = scriptGuid; + } + + internal string Name + { + get => _name ?? string.Empty; + } + + internal string Namespace + { + get => _namespace ?? string.Empty; + } + + internal string ScriptGuid + { + get => _scriptGuid ?? string.Empty; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs.meta new file mode 100644 index 0000000..b0f299f --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphEnumSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d22522f05fcf9121b80bbc5810d4784 diff --git a/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs b/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs new file mode 100644 index 0000000..8f9dfa8 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs @@ -0,0 +1,68 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// What a glyph's drawn strokes take up: the extent of their ink, which proportional text advances by, and the + /// vertices and edges they need, so a layout can size its buffers before writing anything. + /// + internal readonly struct GlyphMetrics + { + internal readonly float InkLeft; + internal readonly float InkRight; + internal readonly float InkBottom; + internal readonly float InkTop; + internal readonly int VertexCount; + internal readonly int EdgeCount; + + private GlyphMetrics(float inkLeft, float inkRight, float inkBottom, float inkTop, int vertexCount, int edgeCount) + { + InkLeft = inkLeft; + InkRight = inkRight; + InkBottom = inkBottom; + InkTop = inkTop; + VertexCount = vertexCount; + EdgeCount = edgeCount; + } + + /// True when at least one stroke draws, so the glyph has ink to measure. + internal bool HasInk + { + get => EdgeCount > 0; + } + + /// Width of the ink, from its leftmost to its rightmost point; zero without ink. + internal float InkWidth + { + get => InkRight - InkLeft; + } + + internal static GlyphMetrics Measure(GlyphStroke[] strokes) + { + float left = float.PositiveInfinity; + float right = float.NegativeInfinity; + float bottom = float.PositiveInfinity; + float top = float.NegativeInfinity; + int vertexCount = 0; + int edgeCount = 0; + foreach (GlyphStroke stroke in strokes) + { + if (!stroke.IsDrawn) + { + continue; + } + foreach (Vector2 point in stroke.Points) + { + left = Math.Min(left, point.x); + right = Math.Max(right, point.x); + bottom = Math.Min(bottom, point.y); + top = Math.Max(top, point.y); + } + vertexCount += stroke.Points.Length; + edgeCount += stroke.EdgeCount; + } + return edgeCount > 0 ? new GlyphMetrics(left, right, bottom, top, vertexCount, edgeCount) : default; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs.meta new file mode 100644 index 0000000..cb3805f --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphMetrics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fea93ac4dd95786488d9fd2b9825483b diff --git a/UnityPackage/Runtime/Glyphs/GlyphStroke.cs b/UnityPackage/Runtime/Glyphs/GlyphStroke.cs new file mode 100644 index 0000000..97786fa --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphStroke.cs @@ -0,0 +1,67 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// One continuous line of a glyph: points in the glyph's box, from 0 to 1 on both axes, joined in order, and back to + /// the first one when closed. + /// + [Serializable] + internal struct GlyphStroke + { + internal const string PointsField = nameof(_points); + internal const string IsClosedField = nameof(_isClosed); + + [SerializeField] private Vector2[] _points; + [SerializeField] private bool _isClosed; + + internal GlyphStroke(Vector2[] points, bool isClosed) + { + _points = points; + _isClosed = isClosed; + } + + /// Points in the glyph's box, in drawing order; never null. + internal Vector2[] Points + { + get => _points ?? Array.Empty(); + } + + /// True when an edge joins the last point back to the first. + internal bool IsClosed + { + get => _isClosed; + } + + /// + /// True when the stroke draws anything: it has at least 2 points, or 3 when closed, and every point is a finite + /// number. Others are skipped, as a polyline with too few points draws nothing. + /// + internal bool IsDrawn + { + get + { + Vector2[] points = Points; + if (points.Length < (_isClosed ? 3 : 2)) + { + return false; + } + for (int i = 0; i < points.Length; i++) + { + if (!float.IsFinite(points[i].x) || !float.IsFinite(points[i].y)) + { + return false; + } + } + return true; + } + } + + /// Number of edges the stroke draws once . + internal int EdgeCount + { + get => _isClosed ? Points.Length : Points.Length - 1; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphStroke.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphStroke.cs.meta new file mode 100644 index 0000000..0155c14 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphStroke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f38df6c06cc1b7988f761dc8486afa82 diff --git a/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs b/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs new file mode 100644 index 0000000..6bd44cd --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs @@ -0,0 +1,37 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// A glyph that draws a symbol, found by its keyword. The keyword names the symbol's member in the pack's generated + /// enum, and lookups go by its hash, the member's value, so they never compare strings. + /// + [Serializable] + internal struct SymbolGlyph + { + internal const string KeywordField = nameof(_keyword); + internal const string StrokesField = nameof(_strokes); + + [SerializeField] private string _keyword; + [SerializeField] private GlyphStroke[] _strokes; + + internal SymbolGlyph(string keyword, GlyphStroke[] strokes) + { + _keyword = keyword; + _strokes = strokes; + } + + /// Name of the symbol, a C# identifier such as ArrowUp; never null. + internal string Keyword + { + get => _keyword ?? string.Empty; + } + + /// The glyph's lines; never null. + internal GlyphStroke[] Strokes + { + get => _strokes ?? Array.Empty(); + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs.meta b/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs.meta new file mode 100644 index 0000000..b51210d --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/SymbolGlyph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e87497443b6e23227f7930b82aebd28 diff --git a/UnityPackage/Runtime/Glyphs/SymbolKeys.cs b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs new file mode 100644 index 0000000..8a19054 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using Unity.Collections.LowLevel.Unsafe; + +namespace reromanlee.Wireframes +{ + /// + /// The numbers symbols are looked up by: the 32-bit FNV-1a hash of their keyword, which is also the value of their + /// member in a pack's generated enum. The same keyword gives the same number in every pack, so a pack higher in a + /// glyph list overrides a symbol of the same name, and lookups never compare strings. + /// + internal static class SymbolKeys + { + /// The key of no symbol, and the value of every generated enum's None member. + internal const int None = 0; + + /// The name of every generated enum's member for no symbol, which no keyword may take. + internal const string NoneName = "None"; + + private const uint OffsetBasis = 2166136261; + private const uint Prime = 16777619; + + private static readonly HashSet ReservedWords = new(StringComparer.Ordinal) + { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", + "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", + "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", + "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", + "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", + "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", + "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while", NoneName + }; + + /// The key of , the hash of its UTF-16 characters. + internal static int Of(string keyword) + { + uint hash = OffsetBasis; + foreach (char character in keyword) + { + hash = unchecked((hash ^ character) * Prime); + } + return unchecked((int)hash); + } + + /// The key a member of a generated symbol enum stands for: its value, read without boxing. + /// doesn't have int values. + internal static int Of(TSymbol symbol) where TSymbol : unmanaged, Enum + { + if (UnsafeUtility.SizeOf() != sizeof(int)) + { + throw new ArgumentException( + $"{typeof(TSymbol).Name} isn't a symbol enum. Symbol enums have int values, like the ones the " + + "Glyph Editor generates.", nameof(symbol)); + } + return UnsafeUtility.As(ref symbol); + } + + /// + /// Returns why can't name a symbol, or null when it can: it has to be a C# identifier + /// of ASCII letters, digits and underscores, not a C# keyword, not None, and not hash to the key of no symbol. + /// + internal static string FindProblem(string keyword) + { + if (string.IsNullOrEmpty(keyword)) + { + return "A symbol needs a keyword."; + } + if (!IsIdentifierStart(keyword[0])) + { + return "A keyword starts with a letter or an underscore."; + } + for (int i = 1; i < keyword.Length; i++) + { + if (!IsIdentifierStart(keyword[i]) && (keyword[i] < '0' || keyword[i] > '9')) + { + return "A keyword has only letters, digits and underscores, from the English alphabet."; + } + } + if (ReservedWords.Contains(keyword)) + { + return keyword == NoneName + ? "None is the name of the generated enum's member for no symbol." + : $"{keyword} is a C# keyword, so it can't name an enum member."; + } + return Of(keyword) == None ? "This keyword's hash is the key of no symbol; pick another one." : null; + } + + private static bool IsIdentifierStart(char character) + { + return character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or '_'; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/SymbolKeys.cs.meta b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs.meta new file mode 100644 index 0000000..6b7abbe --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 16bb93e52a9dd9c13b2f6eae34f53381 diff --git a/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs b/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs new file mode 100644 index 0000000..f866500 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// A pack of glyphs drawn with lines: characters of text, found by character, and symbols, found by the members of + /// the enum the Glyph Editor generates for the pack. Every glyph is drawn in a box from 0 to 1 on both axes, with the + /// baseline 0.25 up from its bottom in every pack. A lists the packs that texts and + /// symbols draw from. Create one with Create > Wireframes > Glyph Pack and edit it in the Glyph Editor. + /// + [CreateAssetMenu(fileName = "Glyph Pack", menuName = "Wireframes/Glyph Pack", order = 1)] + [HelpURL(HelpUrl)] + public sealed class WireframeGlyphPack : ScriptableObject, ISerializationCallbackReceiver + { + internal const string HelpUrl = "https://github.com/reromanlee/Wireframes/blob/main/Documentation/USAGE.md#glyph-packs"; + + /// Version of the pack's data, saved with it so older data can be brought up to date. + internal const int FormatVersion = 1; + + /// Height of the baseline in every pack's glyph box, so characters from different packs line up. + internal const float Baseline = 0.25f; + + internal const float DefaultSpaceWidth = 0.5f; + internal const float DefaultXHeight = 14f / 24f; + internal const float DefaultCapHeight = 20f / 24f; + internal const int DefaultGridDivisions = 12; + internal const int MinimumGridDivisions = 2; + internal const int MaximumGridDivisions = 24; + + internal const string SpaceWidthField = nameof(_spaceWidth); + internal const string XHeightField = nameof(_xHeight); + internal const string CapHeightField = nameof(_capHeight); + internal const string GridDivisionsField = nameof(_gridDivisions); + internal const string CharactersField = nameof(_characters); + internal const string SymbolsField = nameof(_symbols); + internal const string EnumSettingsField = nameof(_enumSettings); + + [SerializeField, HideInInspector] private int _formatVersion = FormatVersion; + + [Tooltip("Width proportional text gives a character without lines, such as a space, in glyph boxes.")] + [SerializeField] private float _spaceWidth = DefaultSpaceWidth; + + [Tooltip("Height of the x-height guide the Glyph Editor draws in the glyph box. Text layout doesn't use it.")] + [SerializeField] private float _xHeight = DefaultXHeight; + + [Tooltip("Height of the cap-height guide the Glyph Editor draws in the glyph box. Text layout doesn't use it.")] + [SerializeField] private float _capHeight = DefaultCapHeight; + + [Tooltip("Divisions of the Glyph Editor's grid across the glyph box, which points snap to.")] + [SerializeField] private int _gridDivisions = DefaultGridDivisions; + + [SerializeField] private CharacterGlyph[] _characters = Array.Empty(); + [SerializeField] private SymbolGlyph[] _symbols = Array.Empty(); + [SerializeField] private GlyphEnumSettings _enumSettings; + + // Built on first use and again after any glyph data changes, which happens only in the Editor. + [NonSerialized] private Dictionary _characterIndices; + [NonSerialized] private Dictionary _symbolIndices; + [NonSerialized] private GlyphMetrics[] _characterMetrics; + [NonSerialized] private GlyphMetrics[] _symbolMetrics; + [NonSerialized] private int _cacheVersion; + [NonSerialized] private bool _hasWarnedAboutFormat; + + /// Width proportional text gives a character without lines, such as a space, in glyph boxes. + internal float SpaceWidth + { + get => _spaceWidth; + } + + internal float XHeight + { + get => _xHeight; + } + + internal float CapHeight + { + get => _capHeight; + } + + internal int GridDivisions + { + get => _gridDivisions; + } + + /// The characters, in the order they are saved; never null. + internal CharacterGlyph[] Characters + { + get => _characters ?? Array.Empty(); + } + + /// The symbols, in the order they are saved, which is the order of their enum's members; never null. + internal SymbolGlyph[] Symbols + { + get => _symbols ?? Array.Empty(); + } + + internal GlyphEnumSettings EnumSettings + { + get => _enumSettings; + set => _enumSettings = value; + } + + /// Finds the glyph of , a Unicode code point; the first one when the pack repeats it. + internal bool TryFindCharacter(int character, out int index) + { + EnsureCache(); + return _characterIndices.TryGetValue(character, out index); + } + + /// Finds the symbol whose keyword hashes to ; the first one when the pack repeats it. + internal bool TryFindSymbol(int key, out int index) + { + EnsureCache(); + return _symbolIndices.TryGetValue(key, out index); + } + + internal GlyphMetrics CharacterMetricsAt(int index) + { + EnsureCache(); + return _characterMetrics[index]; + } + + internal GlyphMetrics SymbolMetricsAt(int index) + { + EnsureCache(); + return _symbolMetrics[index]; + } + + /// Replaces every glyph, for tests and tools that build packs from code. + internal void SetGlyphs(CharacterGlyph[] characters, SymbolGlyph[] symbols) + { + _characters = characters ?? Array.Empty(); + _symbols = symbols ?? Array.Empty(); + GlyphEdits.Record(); + } + + void ISerializationCallbackReceiver.OnBeforeSerialize() + { + } + + void ISerializationCallbackReceiver.OnAfterDeserialize() + { + // Inspector and Glyph Editor edits, undo and reimports all come through here. + GlyphEdits.Record(); + } + + private void OnValidate() + { + if (!float.IsFinite(_spaceWidth) || _spaceWidth < 0f) + { + _spaceWidth = DefaultSpaceWidth; + } + _xHeight = float.IsFinite(_xHeight) ? Mathf.Clamp01(_xHeight) : DefaultXHeight; + _capHeight = float.IsFinite(_capHeight) ? Mathf.Clamp01(_capHeight) : DefaultCapHeight; + _gridDivisions = Mathf.Clamp(_gridDivisions, MinimumGridDivisions, MaximumGridDivisions); + GlyphEdits.Record(); + } + + private void EnsureCache() + { + int version = GlyphEdits.Version; + if (_characterIndices != null && _cacheVersion == version) + { + return; + } + _cacheVersion = version; + WarnAboutNewerFormat(); + + CharacterGlyph[] characters = Characters; + _characterIndices ??= new Dictionary(characters.Length); + _characterIndices.Clear(); + Array.Resize(ref _characterMetrics, characters.Length); + for (int i = 0; i < characters.Length; i++) + { + _characterIndices.TryAdd(characters[i].Character, i); + _characterMetrics[i] = GlyphMetrics.Measure(characters[i].Strokes); + } + + SymbolGlyph[] symbols = Symbols; + _symbolIndices ??= new Dictionary(symbols.Length); + _symbolIndices.Clear(); + Array.Resize(ref _symbolMetrics, symbols.Length); + for (int i = 0; i < symbols.Length; i++) + { + string keyword = symbols[i].Keyword; + if (SymbolKeys.FindProblem(keyword) == null) + { + _symbolIndices.TryAdd(SymbolKeys.Of(keyword), i); + } + _symbolMetrics[i] = GlyphMetrics.Measure(symbols[i].Strokes); + } + } + + private void WarnAboutNewerFormat() + { + if (_formatVersion <= FormatVersion || _hasWarnedAboutFormat) + { + return; + } + _hasWarnedAboutFormat = true; + WireframesLog.Warning( + $"The glyph pack '{name}' was saved by a newer version of Wireframes, so some of its glyphs may draw " + + "wrong. Update the package to draw it as it was made.", this); + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs.meta b/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs.meta new file mode 100644 index 0000000..0fb03fb --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/WireframeGlyphPack.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2921dbb8130f20aae9c1cc1231fb815e diff --git a/UnityPackage/Tests/Runtime/SymbolKeysTests.cs b/UnityPackage/Tests/Runtime/SymbolKeysTests.cs new file mode 100644 index 0000000..1212b50 --- /dev/null +++ b/UnityPackage/Tests/Runtime/SymbolKeysTests.cs @@ -0,0 +1,64 @@ +using System; +using NUnit.Framework; + +namespace reromanlee.Wireframes.Tests +{ + /// The numbers symbols are found by, which generated enums save as their members' values. + public class SymbolKeysTests + { + private enum Sample + { + None = 0, + Heart = -1226833789 + } + + private enum NarrowSample : byte + { + None + } + + [Test] + public void Keys_AreFixedHashesOfTheirKeyword() + { + // Saved enum values depend on these, so the hash must never change between versions or platforms. + Assert.That(SymbolKeys.Of("ArrowUp"), Is.EqualTo(154847355)); + Assert.That(SymbolKeys.Of("Heart"), Is.EqualTo(-1226833789)); + Assert.That(SymbolKeys.Of("Star"), Is.EqualTo(1365736337)); + } + + [Test] + public void EnumMembers_StandForTheirValue() + { + Assert.That(SymbolKeys.Of(Sample.Heart), Is.EqualTo(SymbolKeys.Of("Heart"))); + Assert.That(SymbolKeys.Of(Sample.None), Is.EqualTo(SymbolKeys.None)); + } + + [Test] + public void EnumsWithoutIntValues_AreRejected() + { + Assert.Throws(() => SymbolKeys.Of(NarrowSample.None)); + } + + [TestCase("ArrowUp")] + [TestCase("_Hidden")] + [TestCase("Arrow2")] + [TestCase("Class")] + public void Identifiers_CanBeKeywords(string keyword) + { + Assert.That(SymbolKeys.FindProblem(keyword), Is.Null); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("2Up")] + [TestCase("Arrow Up")] + [TestCase("Arrow-Up")] + [TestCase("Fl\u00E8che")] + [TestCase("class")] + [TestCase("None")] + public void OtherNames_CantBeKeywords(string keyword) + { + Assert.That(SymbolKeys.FindProblem(keyword), Is.Not.Null); + } + } +} diff --git a/UnityPackage/Tests/Runtime/SymbolKeysTests.cs.meta b/UnityPackage/Tests/Runtime/SymbolKeysTests.cs.meta new file mode 100644 index 0000000..6fd3388 --- /dev/null +++ b/UnityPackage/Tests/Runtime/SymbolKeysTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a97c784b69998ed478e74a935777a6b0 From ea8638d0ff86cd3fd68fe4715958e3dbe2eee02a Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 10:55:24 +0300 Subject: [PATCH 03/30] feature: glyph lookups --- UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs | 25 +++ .../Runtime/Glyphs/ResolvedGlyph.cs.meta | 2 + .../Runtime/Glyphs/WireframeGlyphs.cs | 185 ++++++++++++++++++ .../Runtime/Glyphs/WireframeGlyphs.cs.meta | 2 + .../Tests/Runtime/GlyphLookupTests.cs | 135 +++++++++++++ .../Tests/Runtime/GlyphLookupTests.cs.meta | 2 + UnityPackage/Tests/Runtime/GlyphTestBase.cs | 54 +++++ .../Tests/Runtime/GlyphTestBase.cs.meta | 2 + 8 files changed, 407 insertions(+) create mode 100644 UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs create mode 100644 UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs create mode 100644 UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs.meta create mode 100644 UnityPackage/Tests/Runtime/GlyphLookupTests.cs create mode 100644 UnityPackage/Tests/Runtime/GlyphLookupTests.cs.meta create mode 100644 UnityPackage/Tests/Runtime/GlyphTestBase.cs create mode 100644 UnityPackage/Tests/Runtime/GlyphTestBase.cs.meta diff --git a/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs b/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs new file mode 100644 index 0000000..b5c15d3 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs @@ -0,0 +1,25 @@ +namespace reromanlee.Wireframes +{ + /// A glyph found in a : its lines, their metrics and the pack they come from. + internal readonly struct ResolvedGlyph + { + internal readonly GlyphStroke[] Strokes; + internal readonly GlyphMetrics Metrics; + internal readonly WireframeGlyphPack Pack; + + internal ResolvedGlyph(GlyphStroke[] strokes, GlyphMetrics metrics, WireframeGlyphPack pack) + { + Strokes = strokes; + Metrics = metrics; + Pack = pack; + } + + /// + /// Width proportional text gives the glyph: the width of its ink, or its pack's space width when it has none. + /// + internal float ProportionalWidth + { + get => Metrics.HasInk ? Metrics.InkWidth : Pack.SpaceWidth; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs.meta b/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs.meta new file mode 100644 index 0000000..00847a7 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/ResolvedGlyph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 443c1a8f27b88a5e041ad319375cac33 diff --git a/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs b/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs new file mode 100644 index 0000000..7121103 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// The glyph packs that texts and symbols draw from, highest priority first: the first pack that has a character or + /// a symbol draws it, so a pack overrides the ones below it. lists the package's Default Font + /// and Default Symbols. Create your own with Create > Wireframes > Glyphs. + /// + [CreateAssetMenu(fileName = "Glyphs", menuName = "Wireframes/Glyphs", order = 0)] + [HelpURL(WireframeGlyphPack.HelpUrl)] + public sealed class WireframeGlyphs : ScriptableObject, ISerializationCallbackReceiver + { + /// Where loads from, inside the package's Resources folder. + internal const string DefaultResourcePath = "reromanlee.Wireframes/Default Glyphs"; + + internal const string PacksField = nameof(_packs); + + private const int AsciiCount = 128; + + private static WireframeGlyphs _default; + + [Tooltip("Packs to draw from, highest priority first: the first pack that has a character or symbol draws it.")] + [SerializeField] private WireframeGlyphPack[] _packs = Array.Empty(); + + // Built on first use and again after any glyph data changes, which happens only in the Editor. Characters below + // 128 take an array, so most text skips the dictionary. + [NonSerialized] private ResolvedGlyph[] _asciiCharacters; + [NonSerialized] private Dictionary _characters; + [NonSerialized] private Dictionary _symbols; + [NonSerialized] private int _cacheVersion; + + /// + /// The glyphs texts and symbols draw with when they are given none: the package's Default Font, with the 95 + /// printable ASCII characters, and its Default Symbols. + /// + public static WireframeGlyphs Default + { + get + { + if (_default == null) + { + _default = Resources.Load(DefaultResourcePath); + } + return _default; + } + } + + /// The packs, highest priority first. Empty entries are skipped. + public IReadOnlyList Packs + { + get => _packs ?? Array.Empty(); + } + + /// True when one of the packs draws . + public bool Contains(char character) + { + return TryResolveCharacter(character, out _); + } + + /// + /// True when one of the packs draws , a member of an enum the Glyph Editor generated for a + /// pack. Any pack's symbol of the same name counts. + /// + /// doesn't have int values. + public bool Contains(TSymbol symbol) where TSymbol : unmanaged, Enum + { + return TryResolveSymbol(SymbolKeys.Of(symbol), out _); + } + + /// Finds the glyph that draws , a Unicode code point. + internal bool TryResolveCharacter(int character, out ResolvedGlyph glyph) + { + EnsureCache(); + if ((uint)character < AsciiCount) + { + glyph = _asciiCharacters[character]; + return glyph.Strokes != null; + } + return _characters.TryGetValue(character, out glyph); + } + + /// Finds the glyph that draws the symbol whose keyword hashes to . + internal bool TryResolveSymbol(int key, out ResolvedGlyph glyph) + { + EnsureCache(); + return _symbols.TryGetValue(key, out glyph); + } + + /// Replaces the packs, for tests and tools that build glyph lists from code. + internal void SetPacks(params WireframeGlyphPack[] packs) + { + _packs = packs ?? Array.Empty(); + GlyphEdits.Record(); + } + + void ISerializationCallbackReceiver.OnBeforeSerialize() + { + } + + void ISerializationCallbackReceiver.OnAfterDeserialize() + { + // Inspector edits, undo and reimports all come through here. + GlyphEdits.Record(); + } + + private void OnValidate() + { + GlyphEdits.Record(); + } + + private void EnsureCache() + { + int version = GlyphEdits.Version; + if (_asciiCharacters != null && _cacheVersion == version) + { + return; + } + _cacheVersion = version; + _asciiCharacters ??= new ResolvedGlyph[AsciiCount]; + Array.Clear(_asciiCharacters, 0, AsciiCount); + _characters ??= new Dictionary(); + _characters.Clear(); + _symbols ??= new Dictionary(); + _symbols.Clear(); + + // Highest priority first, and a glyph already found is kept, so the first pack that has one wins. + foreach (WireframeGlyphPack pack in Packs) + { + if (pack != null) + { + AddCharacters(pack); + AddSymbols(pack); + } + } + } + + private void AddCharacters(WireframeGlyphPack pack) + { + CharacterGlyph[] characters = pack.Characters; + for (int i = 0; i < characters.Length; i++) + { + int character = characters[i].Character; + // A pack that repeats a character keeps its first glyph. + if (!pack.TryFindCharacter(character, out int first) || first != i) + { + continue; + } + ResolvedGlyph glyph = new(characters[i].Strokes, pack.CharacterMetricsAt(i), pack); + if ((uint)character < AsciiCount) + { + if (_asciiCharacters[character].Strokes == null) + { + _asciiCharacters[character] = glyph; + } + } + else + { + _characters.TryAdd(character, glyph); + } + } + } + + private void AddSymbols(WireframeGlyphPack pack) + { + SymbolGlyph[] symbols = pack.Symbols; + for (int i = 0; i < symbols.Length; i++) + { + string keyword = symbols[i].Keyword; + if (SymbolKeys.FindProblem(keyword) != null) + { + continue; + } + int key = SymbolKeys.Of(keyword); + // A pack that repeats a keyword keeps its first symbol. + if (pack.TryFindSymbol(key, out int first) && first == i) + { + _symbols.TryAdd(key, new ResolvedGlyph(symbols[i].Strokes, pack.SymbolMetricsAt(i), pack)); + } + } + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs.meta b/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs.meta new file mode 100644 index 0000000..0f1824d --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/WireframeGlyphs.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5780c53f243d4bae94968d4d50f81a55 diff --git a/UnityPackage/Tests/Runtime/GlyphLookupTests.cs b/UnityPackage/Tests/Runtime/GlyphLookupTests.cs new file mode 100644 index 0000000..6439799 --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphLookupTests.cs @@ -0,0 +1,135 @@ +using NUnit.Framework; +using UnityEngine; + +namespace reromanlee.Wireframes.Tests +{ + /// Finding glyphs in packs and glyph lists, and measuring them. + public class GlyphLookupTests : GlyphTestBase + { + [Test] + public void FirstPackInTheList_DrawsACharacterOrSymbolBothHave() + { + WireframeGlyphPack mine = CreatePack( + new[] { Character('A', Open(0f, 0f, 1f, 1f)) }, + new[] { Symbol("Heart", Open(0f, 0f, 1f, 1f)) }); + WireframeGlyphPack fallback = CreatePack( + new[] { Character('A', Open(0f, 0f, 0.5f, 0.5f)), Character('B', Open(0f, 0f, 0.5f, 0.5f)) }, + new[] { Symbol("Heart", Open(0f, 0f, 0.5f, 0.5f)), Symbol("Star", Open(0f, 0f, 0.5f, 0.5f)) }); + WireframeGlyphs glyphs = CreateGlyphs(mine, fallback); + + Assert.That(glyphs.TryResolveCharacter('A', out ResolvedGlyph a), Is.True); + Assert.That(a.Pack, Is.SameAs(mine)); + Assert.That(glyphs.TryResolveCharacter('B', out ResolvedGlyph b), Is.True); + Assert.That(b.Pack, Is.SameAs(fallback)); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of("Heart"), out ResolvedGlyph heart), Is.True); + Assert.That(heart.Pack, Is.SameAs(mine)); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of("Star"), out ResolvedGlyph star), Is.True); + Assert.That(star.Pack, Is.SameAs(fallback)); + + glyphs.SetPacks(fallback, mine); + + Assert.That(glyphs.TryResolveCharacter('A', out a) && a.Pack == fallback, Is.True); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of("Heart"), out heart) && heart.Pack == fallback, Is.True); + } + + [Test] + public void PackThatRepeatsAGlyph_KeepsItsFirst() + { + GlyphStroke first = Open(0f, 0f, 1f, 0f); + WireframeGlyphPack pack = CreatePack( + new[] { Character('A', first), Character('A', Open(0f, 0f, 0f, 1f)) }, + new[] { Symbol("Heart", first), Symbol("Heart", Open(0f, 0f, 0f, 1f)) }); + WireframeGlyphs glyphs = CreateGlyphs(pack); + + Assert.That(glyphs.TryResolveCharacter('A', out ResolvedGlyph a), Is.True); + Assert.That(a.Strokes[0].Points[1], Is.EqualTo(new Vector2(1f, 0f))); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of("Heart"), out ResolvedGlyph heart), Is.True); + Assert.That(heart.Strokes[0].Points[1], Is.EqualTo(new Vector2(1f, 0f))); + } + + [Test] + public void Lookups_SkipEmptyEntriesAndInvalidKeywords() + { + WireframeGlyphPack pack = CreatePack( + new[] { Character('A', Open(0f, 0f, 1f, 1f)) }, + new[] { Symbol("class", Open(0f, 0f, 1f, 1f)), Symbol("", Open(0f, 0f, 1f, 1f)) }); + WireframeGlyphs glyphs = CreateGlyphs(null, pack, null); + + Assert.That(glyphs.Contains('A'), Is.True); + Assert.That(glyphs.Contains('B'), Is.False); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of("class"), out _), Is.False); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.Of(""), out _), Is.False); + Assert.That(glyphs.TryResolveSymbol(SymbolKeys.None, out _), Is.False); + } + + [Test] + public void CharactersPastAscii_AreFoundToo() + { + const int grinningFace = 0x1F600; + WireframeGlyphPack pack = CreatePack(new[] + { + Character('\u0416', Open(0f, 0f, 1f, 1f)), + new CharacterGlyph(grinningFace, new[] { Open(0f, 0f, 1f, 1f) }) + }); + WireframeGlyphs glyphs = CreateGlyphs(pack); + + Assert.That(glyphs.Contains('\u0416'), Is.True); + Assert.That(glyphs.TryResolveCharacter(grinningFace, out _), Is.True); + Assert.That(glyphs.Contains('\u0401'), Is.False); + } + + [Test] + public void EditedPack_IsReadAgainByEveryList() + { + WireframeGlyphPack pack = CreatePack(new[] { Character('A', Open(0f, 0f, 1f, 1f)) }); + WireframeGlyphs glyphs = CreateGlyphs(pack); + Assert.That(glyphs.Contains('A'), Is.True); + + pack.SetGlyphs(new[] { Character('B', Open(0f, 0f, 1f, 1f)) }, null); + + Assert.That(glyphs.Contains('A'), Is.False); + Assert.That(glyphs.Contains('B'), Is.True); + } + + [Test] + public void Metrics_MeasureOnlyTheStrokesThatDraw() + { + GlyphStroke[] strokes = + { + Open(0.25f, 0.25f, 0.5f, 0.75f, 0.75f, 0.25f), + Closed(0.4f, 0.4f, 0.6f, 0.4f, 0.5f, 0.5f), + // Too few points, or a point that isn't a number, draw nothing. + Open(0f, 1f), + Closed(0.9f, 0.9f, 1f, 1f), + Open(0.1f, 0.1f, float.NaN, 0.1f) + }; + + GlyphMetrics metrics = GlyphMetrics.Measure(strokes); + + Assert.That(metrics.HasInk, Is.True); + Assert.That(metrics.InkLeft, Is.EqualTo(0.25f)); + Assert.That(metrics.InkRight, Is.EqualTo(0.75f)); + Assert.That(metrics.InkBottom, Is.EqualTo(0.25f)); + Assert.That(metrics.InkTop, Is.EqualTo(0.75f)); + Assert.That(metrics.VertexCount, Is.EqualTo(3 + 3)); + Assert.That(metrics.EdgeCount, Is.EqualTo(2 + 3)); + } + + [Test] + public void ProportionalWidth_IsTheInkWidthOrThePacksSpaceWidth() + { + WireframeGlyphPack pack = CreatePack(new[] + { + Character('I', Open(0.4f, 0.25f, 0.6f, 0.75f)), + Character(' ') + }); + WireframeGlyphs glyphs = CreateGlyphs(pack); + + Assert.That(glyphs.TryResolveCharacter('I', out ResolvedGlyph letter), Is.True); + AssertApproximately(0.2f, letter.ProportionalWidth); + Assert.That(glyphs.TryResolveCharacter(' ', out ResolvedGlyph space), Is.True); + Assert.That(space.Metrics.HasInk, Is.False); + Assert.That(space.ProportionalWidth, Is.EqualTo(WireframeGlyphPack.DefaultSpaceWidth)); + } + } +} diff --git a/UnityPackage/Tests/Runtime/GlyphLookupTests.cs.meta b/UnityPackage/Tests/Runtime/GlyphLookupTests.cs.meta new file mode 100644 index 0000000..d90a89c --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphLookupTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c78206ad7767a7a46368e7e65db4ef71 diff --git a/UnityPackage/Tests/Runtime/GlyphTestBase.cs b/UnityPackage/Tests/Runtime/GlyphTestBase.cs new file mode 100644 index 0000000..a432375 --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphTestBase.cs @@ -0,0 +1,54 @@ +using UnityEngine; + +namespace reromanlee.Wireframes.Tests +{ + /// Builds glyph packs and glyph lists in code, with glyphs simple enough to check by hand. + public abstract class GlyphTestBase : WireframesTestBase + { + private protected WireframeGlyphPack CreatePack(CharacterGlyph[] characters, SymbolGlyph[] symbols = null) + { + WireframeGlyphPack pack = Track(ScriptableObject.CreateInstance()); + pack.SetGlyphs(characters, symbols); + return pack; + } + + private protected WireframeGlyphs CreateGlyphs(params WireframeGlyphPack[] packs) + { + WireframeGlyphs glyphs = Track(ScriptableObject.CreateInstance()); + glyphs.SetPacks(packs); + return glyphs; + } + + private protected static CharacterGlyph Character(char character, params GlyphStroke[] strokes) + { + return new CharacterGlyph(character, strokes); + } + + private protected static SymbolGlyph Symbol(string keyword, params GlyphStroke[] strokes) + { + return new SymbolGlyph(keyword, strokes); + } + + /// An open stroke through points given as x, y pairs. + private protected static GlyphStroke Open(params float[] coordinates) + { + return new GlyphStroke(Points(coordinates), false); + } + + /// A closed stroke through points given as x, y pairs. + private protected static GlyphStroke Closed(params float[] coordinates) + { + return new GlyphStroke(Points(coordinates), true); + } + + private static Vector2[] Points(float[] coordinates) + { + Vector2[] points = new Vector2[coordinates.Length / 2]; + for (int i = 0; i < points.Length; i++) + { + points[i] = new Vector2(coordinates[i * 2], coordinates[i * 2 + 1]); + } + return points; + } + } +} diff --git a/UnityPackage/Tests/Runtime/GlyphTestBase.cs.meta b/UnityPackage/Tests/Runtime/GlyphTestBase.cs.meta new file mode 100644 index 0000000..2d4609e --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphTestBase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b42c7da147d2bce645b2b995c4e1003e From 108be937d46e99e9fe8654988e8d8b1a158b899f Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:01:12 +0300 Subject: [PATCH 04/30] feature: text layout --- UnityPackage/Runtime/Glyphs/GlyphGeometry.cs | 113 +++++++ .../Runtime/Glyphs/GlyphGeometry.cs.meta | 2 + UnityPackage/Runtime/Glyphs/GlyphWarnings.cs | 55 ++++ .../Runtime/Glyphs/GlyphWarnings.cs.meta | 2 + UnityPackage/Runtime/Shapes/Text.meta | 8 + .../Runtime/Shapes/Text/TextLayout.cs | 275 ++++++++++++++++++ .../Runtime/Shapes/Text/TextLayout.cs.meta | 2 + .../Runtime/Shapes/Text/TextSettings.cs | 49 ++++ .../Runtime/Shapes/Text/TextSettings.cs.meta | 2 + .../Shapes/Text/WireframeCharacterWidth.cs | 15 + .../Text/WireframeCharacterWidth.cs.meta | 2 + .../Text/WireframeHorizontalAlignment.cs | 15 + .../Text/WireframeHorizontalAlignment.cs.meta | 2 + .../Shapes/Text/WireframeTextOverflow.cs | 14 + .../Shapes/Text/WireframeTextOverflow.cs.meta | 2 + .../Shapes/Text/WireframeVerticalAlignment.cs | 15 + .../Text/WireframeVerticalAlignment.cs.meta | 2 + UnityPackage/Tests/Runtime/TextLayoutTests.cs | 224 ++++++++++++++ .../Tests/Runtime/TextLayoutTests.cs.meta | 2 + 19 files changed, 801 insertions(+) create mode 100644 UnityPackage/Runtime/Glyphs/GlyphGeometry.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphGeometry.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphWarnings.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphWarnings.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/TextLayout.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/TextLayout.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/TextSettings.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/TextSettings.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs.meta create mode 100644 UnityPackage/Tests/Runtime/TextLayoutTests.cs create mode 100644 UnityPackage/Tests/Runtime/TextLayoutTests.cs.meta diff --git a/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs b/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs new file mode 100644 index 0000000..e73a32e --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs @@ -0,0 +1,113 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// The points and edges of laid-out glyphs, kept in buffers with room to spare: they grow by doubling and shrink only + /// once a quarter is used, so a text that keeps changing reuses them instead of allocating. Their capacity is the + /// vertex count the shape takes in its chunk, and points past are never joined by an edge. + /// + internal sealed class GlyphGeometry + { + private const int MinimumCapacity = 16; + + private Vector3[] _points = new Vector3[MinimumCapacity]; + private int[] _pattern = new int[MinimumCapacity * 2]; + + /// Points written so far, in the shape's local space. + internal int VertexCount { get; private set; } + + /// Edges written so far, as pairs in . + internal int EdgeCount { get; private set; } + + /// Points the buffers hold, at least 16. + internal int VertexCapacity + { + get => _points.Length; + } + + internal Vector3[] Points + { + get => _points; + } + + /// Pairs of indices into , one pair per edge. + internal int[] Pattern + { + get => _pattern; + } + + /// Empties the buffers and makes room for points and edges. + internal void Begin(int vertexCount, int edgeCount) + { + VertexCount = 0; + EdgeCount = 0; + int vertexCapacity = FittedCapacity(_points.Length, vertexCount); + if (vertexCapacity != _points.Length) + { + _points = new Vector3[vertexCapacity]; + } + int edgeCapacity = FittedCapacity(_pattern.Length / 2, edgeCount); + if (edgeCapacity != _pattern.Length / 2) + { + _pattern = new int[edgeCapacity * 2]; + } + } + + /// + /// Writes the strokes that draw, as tells, with each glyph-box point placed at + /// plus the point scaled by in the XY plane. + /// + internal void AddGlyph(GlyphStroke[] strokes, Vector2 origin, float scale) + { + foreach (GlyphStroke stroke in strokes) + { + if (!stroke.IsDrawn) + { + continue; + } + Vector2[] points = stroke.Points; + int first = VertexCount; + for (int i = 0; i < points.Length; i++) + { + _points[first + i] = new Vector3(origin.x + points[i].x * scale, origin.y + points[i].y * scale, 0f); + } + VertexCount += points.Length; + int last = VertexCount - 1; + for (int vertex = first; vertex < last; vertex++) + { + AddEdge(vertex, vertex + 1); + } + if (stroke.IsClosed) + { + AddEdge(last, first); + } + } + } + + private void AddEdge(int vertexA, int vertexB) + { + _pattern[EdgeCount * 2] = vertexA; + _pattern[EdgeCount * 2 + 1] = vertexB; + EdgeCount++; + } + + /// + /// The capacity to hold : doubled from the current one when it is too small, halved + /// while a quarter or less of it is used, and never below . + /// + private static int FittedCapacity(int capacity, int required) + { + while (capacity < required) + { + capacity *= 2; + } + while (capacity > MinimumCapacity && required <= capacity / 4) + { + capacity /= 2; + } + return capacity; + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs.meta new file mode 100644 index 0000000..ef07c7d --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphGeometry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9025253c019826c8a973ef352133e21e diff --git a/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs b/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs new file mode 100644 index 0000000..7d51db8 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + /// + /// Warnings about glyphs that can't be found, each logged once per glyph list and character or symbol, so a text + /// updated every frame doesn't flood the Console. Main thread only. + /// + internal static class GlyphWarnings + { + private static readonly HashSet<(WireframeGlyphs Glyphs, int Value, bool IsSymbol)> Reported = new(); + private static bool _hasReportedMissingDefault; + + /// Warns that a text or symbol was given no glyphs and the package's default ones are missing. + internal static void ReportMissingDefault(Object context) + { + if (_hasReportedMissingDefault) + { + return; + } + _hasReportedMissingDefault = true; + WireframesLog.Warning( + "There are no glyphs to draw with: the package's Default Glyphs asset is missing. Reinstall the package, " + + "or give texts and symbols glyphs of your own.", context); + } + + /// Warns that lack , which is drawn as ? instead. + internal static void ReportMissingCharacter(WireframeGlyphs glyphs, int codePoint, Object context) + { + if (!Reported.Add((glyphs, codePoint, false))) + { + return; + } + // Lone surrogates aren't characters on their own, so only their code is shown. + bool isCharacter = codePoint is >= 0 and < 0xD800 or > 0xDFFF and <= 0x10FFFF; + string shown = isCharacter ? $"'{char.ConvertFromUtf32(codePoint)}' (U+{codePoint:X4})" : $"U+{codePoint:X4}"; + WireframesLog.Warning( + $"{shown} isn't in the glyphs '{glyphs.name}', so it's drawn as '?'. Add it to one of their packs in the " + + "Glyph Editor, or add a pack that has it.", context); + } + + /// Warns that lack the symbol of , which is drawn as ? instead. + internal static void ReportMissingSymbol(WireframeGlyphs glyphs, int key, Object context) + { + if (!Reported.Add((glyphs, key, true))) + { + return; + } + WireframesLog.Warning( + $"A symbol isn't in the glyphs '{glyphs.name}', so it's drawn as '?'. Its keyword may have been renamed or " + + "removed: pick the symbol again, or add a pack that has it.", context); + } + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs.meta new file mode 100644 index 0000000..58597f0 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphWarnings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d5b0a6a0a05504fcb1d2f27e5c767277 diff --git a/UnityPackage/Runtime/Shapes/Text.meta b/UnityPackage/Runtime/Shapes/Text.meta new file mode 100644 index 0000000..a093b20 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0764bee4dfd5cddd87bde150663facc3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Shapes/Text/TextLayout.cs b/UnityPackage/Runtime/Shapes/Text/TextLayout.cs new file mode 100644 index 0000000..daface7 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextLayout.cs @@ -0,0 +1,275 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + /// + /// Lays text out into glyph geometry: finds each character's glyph, breaks the characters into lines, and places the + /// lines in the text's bounds. Text lies in the XY plane, read from the -Z side, with +Y up. Its buffers are shared + /// and only grow, so laying out text allocates nothing once they are big enough. Main thread only. + /// + internal static class TextLayout + { + private const int SpacesPerTab = 4; + private const char Fallback = '?'; + private const char Space = ' '; + + private static Item[] _items = new Item[64]; + private static Line[] _lines = new Line[8]; + + private enum ItemKind : byte + { + Glyph, + Space, + LineBreak + } + + /// + /// Lays out with into . Characters + /// the glyphs lack are drawn as '?', with one warning for each, shown with . + /// + internal static void Layout( + ReadOnlySpan text, WireframeGlyphs glyphs, in TextSettings settings, GlyphGeometry geometry, + Object context) + { + int itemCount = FindGlyphs(text, glyphs, settings, context, out int vertexCount, out int edgeCount); + int lineCount = BreakLines(itemCount, settings); + geometry.Begin(vertexCount, edgeCount); + PlaceLines(lineCount, settings, geometry); + // The shared items would otherwise keep the glyphs' packs referenced. + Array.Clear(_items, 0, itemCount); + } + + /// Turns the characters into items that know their glyph and how far they move the next one along. + private static int FindGlyphs( + ReadOnlySpan text, WireframeGlyphs glyphs, in TextSettings settings, Object context, + out int vertexCount, out int edgeCount) + { + bool isMonospace = settings.CharacterWidth == WireframeCharacterWidth.Monospace; + float spacing = settings.CharacterSpacing; + if (glyphs == null) + { + GlyphWarnings.ReportMissingDefault(context); + } + float spaceAdvance = SpaceWidth(glyphs, isMonospace) + spacing; + EnsureItemCapacity(text.Length); + int count = 0; + vertexCount = 0; + edgeCount = 0; + for (int i = 0; i < text.Length; i++) + { + char character = text[i]; + if (character == '\n') + { + _items[count++] = new Item(default, 0f, 0f, ItemKind.LineBreak); + continue; + } + if (character == '\t') + { + _items[count++] = new Item(default, spaceAdvance * SpacesPerTab, 0f, ItemKind.Space); + continue; + } + // Carriage returns too, so Windows line endings break lines once. + if (char.IsControl(character)) + { + continue; + } + int codePoint = character; + if (char.IsHighSurrogate(character) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + codePoint = char.ConvertToUtf32(character, text[++i]); + } + ItemKind kind = codePoint == Space ? ItemKind.Space : ItemKind.Glyph; + if (glyphs == null || !glyphs.TryResolveCharacter(codePoint, out ResolvedGlyph glyph)) + { + // A space draws nothing anyway, so one missing from the glyphs isn't worth a warning. + if (codePoint == Space) + { + _items[count++] = new Item(default, spaceAdvance, 0f, ItemKind.Space); + continue; + } + if (glyphs != null) + { + GlyphWarnings.ReportMissingCharacter(glyphs, codePoint, context); + } + if (glyphs == null || !glyphs.TryResolveCharacter(Fallback, out glyph)) + { + _items[count++] = new Item(default, spaceAdvance, 0f, ItemKind.Glyph); + continue; + } + } + GlyphMetrics metrics = glyph.Metrics; + float advance = (isMonospace ? 1f : glyph.ProportionalWidth) + spacing; + // Proportional characters move their ink to the pen, so no glyph keeps the empty room of its box. + float inkOffset = isMonospace || !metrics.HasInk ? 0f : -metrics.InkLeft; + _items[count++] = new Item(glyph, advance, inkOffset, kind); + vertexCount += metrics.VertexCount; + edgeCount += metrics.EdgeCount; + } + return count; + } + + /// + /// Splits the items into lines at line breaks and, when the text wraps, at the last space that keeps a line within + /// the bounds, or before the character that doesn't fit when the line has no space. The space a line breaks at is + /// dropped, and a line always keeps at least one character. + /// + private static int BreakLines(int itemCount, in TextSettings settings) + { + float spacing = settings.CharacterSpacing; + float size = Math.Abs(settings.CharacterSize); + bool wraps = settings.Overflow == WireframeTextOverflow.Wrap && size > 0f; + float limit = wraps ? Math.Abs(settings.Bounds.x) / size : float.PositiveInfinity; + int lineCount = 0; + int lineStart = 0; + float width = 0f; + int breakIndex = -1; + float widthBeforeBreak = 0f; + for (int i = 0; i < itemCount; i++) + { + Item item = _items[i]; + if (item.Kind == ItemKind.LineBreak) + { + AddLine(ref lineCount, lineStart, i, width, spacing); + lineStart = i + 1; + width = 0f; + breakIndex = -1; + continue; + } + if (item.Kind == ItemKind.Space) + { + // Spaces never start a new line themselves: one may hang past the bounds until a character needs it. + breakIndex = i; + widthBeforeBreak = width; + width += item.Advance; + continue; + } + // Without its spacing, which stays out of the bounds at the end of a line. + while (wraps && i > lineStart && width + item.Advance - spacing > limit) + { + if (breakIndex >= lineStart) + { + AddLine(ref lineCount, lineStart, breakIndex, widthBeforeBreak, spacing); + width -= widthBeforeBreak + _items[breakIndex].Advance; + lineStart = breakIndex + 1; + breakIndex = -1; + } + else + { + AddLine(ref lineCount, lineStart, i, width, spacing); + lineStart = i; + width = 0f; + } + } + width += item.Advance; + } + AddLine(ref lineCount, lineStart, itemCount, width, spacing); + return lineCount; + } + + /// Places each line by the alignments, from the top line down, and writes the glyphs of its items. + private static void PlaceLines(int lineCount, in TextSettings settings, GlyphGeometry geometry) + { + float size = settings.CharacterSize; + float lineStep = (1f + settings.LineSpacing) * size; + float blockHeight = (lineCount + (lineCount - 1) * settings.LineSpacing) * size; + float halfWidth = settings.Bounds.x * 0.5f; + float halfHeight = settings.Bounds.y * 0.5f; + float top = settings.VerticalAlignment switch + { + WireframeVerticalAlignment.Top => halfHeight, + WireframeVerticalAlignment.Bottom => blockHeight - halfHeight, + _ => blockHeight * 0.5f + }; + for (int line = 0; line < lineCount; line++) + { + Line record = _lines[line]; + float width = record.Width * size; + float left = settings.HorizontalAlignment switch + { + WireframeHorizontalAlignment.Left => -halfWidth, + WireframeHorizontalAlignment.Right => halfWidth - width, + _ => -width * 0.5f + }; + float bottom = top - line * lineStep - size; + float pen = 0f; + for (int i = record.Start; i < record.End; i++) + { + Item item = _items[i]; + if (item.Glyph.Strokes != null) + { + geometry.AddGlyph(item.Glyph.Strokes, new Vector2(left + (pen + item.InkOffset) * size, bottom), size); + } + pen += item.Advance; + } + } + } + + /// Width a space takes: the glyphs' own space, or the default when they have none. + private static float SpaceWidth(WireframeGlyphs glyphs, bool isMonospace) + { + if (isMonospace) + { + return 1f; + } + return glyphs != null && glyphs.TryResolveCharacter(Space, out ResolvedGlyph space) + ? space.ProportionalWidth + : WireframeGlyphPack.DefaultSpaceWidth; + } + + private static void AddLine(ref int lineCount, int start, int end, float width, float spacing) + { + if (lineCount == _lines.Length) + { + Array.Resize(ref _lines, lineCount * 2); + } + // The last character's spacing is room after the line, not part of it. + _lines[lineCount++] = new Line(start, end, end > start ? width - spacing : 0f); + } + + private static void EnsureItemCapacity(int count) + { + if (count <= _items.Length) + { + return; + } + int capacity = _items.Length; + while (capacity < count) + { + capacity *= 2; + } + _items = new Item[capacity]; + } + + private readonly struct Item + { + internal readonly ResolvedGlyph Glyph; + internal readonly float Advance; + internal readonly float InkOffset; + internal readonly ItemKind Kind; + + internal Item(ResolvedGlyph glyph, float advance, float inkOffset, ItemKind kind) + { + Glyph = glyph; + Advance = advance; + InkOffset = inkOffset; + Kind = kind; + } + } + + private readonly struct Line + { + internal readonly int Start; + internal readonly int End; + internal readonly float Width; + + internal Line(int start, int end, float width) + { + Start = start; + End = end; + Width = width; + } + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/TextLayout.cs.meta b/UnityPackage/Runtime/Shapes/Text/TextLayout.cs.meta new file mode 100644 index 0000000..596cd51 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextLayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 50e3507d98fa32a851e530ad31406375 diff --git a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs new file mode 100644 index 0000000..789ed0c --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs @@ -0,0 +1,49 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// How a text lays out its characters, shared by and . + internal struct TextSettings + { + internal const float DefaultCharacterSpacing = 0.1f; + internal const float DefaultLineSpacing = 0f; + + internal static readonly Vector2 DefaultBounds = new(4f, 1f); + + internal WireframeCharacterWidth CharacterWidth; + + /// Height of a glyph box in local units. + internal float CharacterSize; + + /// Extra room after each character, in glyph boxes. + internal float CharacterSpacing; + + /// Extra room between lines, in glyph boxes. + internal float LineSpacing; + + /// Width and height of the rectangle the text is aligned in, centered on the shape, in local units. + internal Vector2 Bounds; + + internal WireframeHorizontalAlignment HorizontalAlignment; + internal WireframeVerticalAlignment VerticalAlignment; + internal WireframeTextOverflow Overflow; + + /// + /// Proportional characters 1 unit tall, centered on the shape in 4 by 1 bounds, with lines kept as written. + /// + internal static TextSettings Default + { + get => new() + { + CharacterWidth = WireframeCharacterWidth.Proportional, + CharacterSize = ShapeDefaults.Size, + CharacterSpacing = DefaultCharacterSpacing, + LineSpacing = DefaultLineSpacing, + Bounds = DefaultBounds, + HorizontalAlignment = WireframeHorizontalAlignment.Center, + VerticalAlignment = WireframeVerticalAlignment.Middle, + Overflow = WireframeTextOverflow.Overflow + }; + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs.meta b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs.meta new file mode 100644 index 0000000..715eaf0 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a65536289b1c88a84b1c9dad9dfdceb8 diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs b/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs new file mode 100644 index 0000000..b9e3538 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs @@ -0,0 +1,15 @@ +namespace reromanlee.Wireframes +{ + /// How far each character of a text moves the next one along. + public enum WireframeCharacterWidth + { + /// + /// Each character takes the width of its lines, so narrow ones sit closer together, and a character without + /// lines, such as a space, takes its pack's space width. + /// + Proportional, + + /// Every character takes its whole glyph box, 1 wide, so columns line up. + Monospace + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs.meta b/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs.meta new file mode 100644 index 0000000..33fee13 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeCharacterWidth.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf7bcea50410c725bef9c03063059fcf diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs b/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs new file mode 100644 index 0000000..5d5e1dc --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs @@ -0,0 +1,15 @@ +namespace reromanlee.Wireframes +{ + /// Where each line of a text sits across its bounds. + public enum WireframeHorizontalAlignment + { + /// Lines start at the left edge of the bounds. + Left, + + /// Lines are centered between the edges of the bounds. + Center, + + /// Lines end at the right edge of the bounds. + Right + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs.meta b/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs.meta new file mode 100644 index 0000000..3b0e045 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeHorizontalAlignment.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5312348d833e1ce21c0a710b3bb26f8e diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs b/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs new file mode 100644 index 0000000..bcff8bb --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs @@ -0,0 +1,14 @@ +namespace reromanlee.Wireframes +{ + /// What a text does with lines wider than its bounds. + public enum WireframeTextOverflow + { + /// Lines stay as written and run past the bounds; only line breaks in the text start new lines. + Overflow, + + /// + /// Lines break at spaces to fit the width of the bounds, and inside a word only when the word alone is wider. + /// + Wrap + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs.meta b/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs.meta new file mode 100644 index 0000000..b64656e --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeTextOverflow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 45b2fb686f6dbabccd144c79ff8435df diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs b/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs new file mode 100644 index 0000000..07699d4 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs @@ -0,0 +1,15 @@ +namespace reromanlee.Wireframes +{ + /// Where the lines of a text sit, all together, between the top and the bottom of its bounds. + public enum WireframeVerticalAlignment + { + /// The first line's glyph boxes touch the top of the bounds, and further lines go down. + Top, + + /// The lines are centered between the top and the bottom of the bounds. + Middle, + + /// The last line's glyph boxes touch the bottom of the bounds, and earlier lines go up. + Bottom + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs.meta b/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs.meta new file mode 100644 index 0000000..a27df7f --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeVerticalAlignment.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 60692e84f006850096741669dac02806 diff --git a/UnityPackage/Tests/Runtime/TextLayoutTests.cs b/UnityPackage/Tests/Runtime/TextLayoutTests.cs new file mode 100644 index 0000000..c1fea58 --- /dev/null +++ b/UnityPackage/Tests/Runtime/TextLayoutTests.cs @@ -0,0 +1,224 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace reromanlee.Wireframes.Tests +{ + /// + /// Laying text out into points and edges. The glyphs are simple: A is a line from 0.2 to 0.8 on the baseline, B a + /// closed square from 0.25 to 0.75, and ? a line from 0.4 to 0.6. + /// + public class TextLayoutTests : GlyphTestBase + { + private const float Baseline = WireframeGlyphPack.Baseline; + + private readonly GlyphGeometry _geometry = new(); + + [Test] + public void Proportional_AdvancesByInkWidthAndSpacing() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0.1f); + + TextLayout.Layout("AB", CreateTestGlyphs(), settings, _geometry, null); + + Assert.That(_geometry.VertexCount, Is.EqualTo(2 + 4)); + Assert.That(_geometry.EdgeCount, Is.EqualTo(1 + 4)); + // Each glyph's ink starts at the pen: A at the left edge, B after A's 0.6 of ink and 0.1 of spacing. + AssertApproximately(new Vector3(-5f, 1f - 1f + Baseline, 0f), _geometry.Points[0]); + AssertApproximately(new Vector3(-5f + 0.7f, Baseline, 0f), _geometry.Points[2]); + AssertApproximately(new Vector3(-5f + 0.7f + 0.5f, Baseline + 0.5f, 0f), _geometry.Points[4]); + } + + [Test] + public void Monospace_AdvancesByWholeBoxes() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Monospace, 0f); + + TextLayout.Layout("AB", CreateTestGlyphs(), settings, _geometry, null); + + AssertApproximately(new Vector3(-5f + 0.2f, Baseline, 0f), _geometry.Points[0]); + AssertApproximately(new Vector3(-5f + 1f + 0.25f, Baseline, 0f), _geometry.Points[2]); + } + + [Test] + public void Edges_JoinEachStrokeInOrderAndCloseClosedOnes() + { + TextLayout.Layout("AB", CreateTestGlyphs(), TopLeft(WireframeCharacterWidth.Proportional, 0f), _geometry, null); + + int[] expected = { 0, 1, 2, 3, 3, 4, 4, 5, 5, 2 }; + for (int i = 0; i < expected.Length; i++) + { + Assert.That(_geometry.Pattern[i], Is.EqualTo(expected[i]), $"Index {i}"); + } + } + + [Test] + public void Alignments_PlaceTheLinesInTheBounds() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + WireframeGlyphs glyphs = CreateTestGlyphs(); + + settings.HorizontalAlignment = WireframeHorizontalAlignment.Center; + settings.VerticalAlignment = WireframeVerticalAlignment.Middle; + TextLayout.Layout("A", glyphs, settings, _geometry, null); + AssertApproximately(new Vector3(-0.3f, -0.5f + Baseline, 0f), _geometry.Points[0]); + + settings.HorizontalAlignment = WireframeHorizontalAlignment.Right; + settings.VerticalAlignment = WireframeVerticalAlignment.Bottom; + TextLayout.Layout("A", glyphs, settings, _geometry, null); + AssertApproximately(new Vector3(5f - 0.6f, -1f + Baseline, 0f), _geometry.Points[0]); + } + + [Test] + public void CharacterSize_ScalesEverything() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0.1f); + settings.CharacterSize = 2f; + + TextLayout.Layout("AB", CreateTestGlyphs(), settings, _geometry, null); + + AssertApproximately(new Vector3(-5f, 1f - 2f + Baseline * 2f, 0f), _geometry.Points[0]); + AssertApproximately(new Vector3(-5f + 0.7f * 2f, -1f + Baseline * 2f, 0f), _geometry.Points[2]); + } + + [Test] + public void LineBreaks_StartLinesFurtherDown() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + settings.LineSpacing = 0.5f; + + TextLayout.Layout("A\nA\r\nA", CreateTestGlyphs(), settings, _geometry, null); + + Assert.That(_geometry.VertexCount, Is.EqualTo(6)); + AssertApproximately(new Vector3(-5f, Baseline, 0f), _geometry.Points[0]); + AssertApproximately(new Vector3(-5f, Baseline - 1.5f, 0f), _geometry.Points[2]); + AssertApproximately(new Vector3(-5f, Baseline - 3f, 0f), _geometry.Points[4]); + } + + [Test] + public void Wrap_BreaksAtTheLastSpaceThatFits() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + settings.Overflow = WireframeTextOverflow.Wrap; + // AB is 1.1 wide and a space 0.5, so "AB AB" takes 2.7 and a third word needs a line of its own. + settings.Bounds = new Vector2(3f, 2f); + + TextLayout.Layout("AB AB AB", CreateTestGlyphs(), settings, _geometry, null); + + Assert.That(_geometry.VertexCount, Is.EqualTo(3 * 6)); + AssertApproximately(new Vector3(-1.5f + 1.6f, 1f - 1f + Baseline, 0f), _geometry.Points[6]); + AssertApproximately(new Vector3(-1.5f, 1f - 2f + Baseline, 0f), _geometry.Points[12]); + } + + [Test] + public void Wrap_BreaksInsideAWordOnlyWhenItIsWiderThanTheBounds() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + settings.Overflow = WireframeTextOverflow.Wrap; + settings.Bounds = new Vector2(2f, 2f); + + TextLayout.Layout("ABABAB", CreateTestGlyphs(), settings, _geometry, null); + + // A, B and A take 1.7, so the second B starts the second line. + AssertApproximately(new Vector3(-1f + 1.1f, Baseline, 0f), _geometry.Points[6]); + AssertApproximately(new Vector3(-1f, Baseline - 1f, 0f), _geometry.Points[8]); + } + + [Test] + public void Overflow_KeepsLinesAsWritten() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + settings.Bounds = new Vector2(1f, 2f); + + TextLayout.Layout("AB AB", CreateTestGlyphs(), settings, _geometry, null); + + AssertApproximately(new Vector3(-0.5f + 1.6f, Baseline, 0f), _geometry.Points[6]); + } + + [Test] + public void TabsAndControlCharacters_AdvanceOrDrawNothing() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + + TextLayout.Layout("A\t\u0007B", CreateTestGlyphs(), settings, _geometry, null); + + // A tab advances 4 spaces of 0.5. + AssertApproximately(new Vector3(-5f + 0.6f + 2f, Baseline, 0f), _geometry.Points[2]); + } + + [Test] + public void UnsupportedCharacters_AreDrawnAsQuestionMarksWithOneWarning() + { + TextSettings settings = TopLeft(WireframeCharacterWidth.Proportional, 0f); + WireframeGlyphs glyphs = CreateTestGlyphs(); + LogAssert.Expect(LogType.Warning, new Regex(@"'Z' \(U\+005A\) isn't in the glyphs")); + + TextLayout.Layout("AZZ", glyphs, settings, _geometry, null); + TextLayout.Layout("AZZ", glyphs, settings, _geometry, null); + + LogAssert.NoUnexpectedReceived(); + // Both drawn as ?, 0.2 wide and starting at the pen. + Assert.That(_geometry.VertexCount, Is.EqualTo(2 + 2 + 2)); + AssertApproximately(new Vector3(-5f + 0.6f, Baseline, 0f), _geometry.Points[2]); + AssertApproximately(new Vector3(-5f + 0.8f, Baseline, 0f), _geometry.Points[4]); + } + + [Test] + public void SpaceMissingFromTheGlyphs_StillSeparatesWords() + { + WireframeGlyphs glyphs = CreateGlyphs(CreatePack(new[] { Character('A', Open(0.2f, Baseline, 0.8f, Baseline)) })); + + TextLayout.Layout("A A", glyphs, TopLeft(WireframeCharacterWidth.Proportional, 0f), _geometry, null); + + AssertApproximately(new Vector3(-5f + 0.6f + WireframeGlyphPack.DefaultSpaceWidth, Baseline, 0f), _geometry.Points[2]); + LogAssert.NoUnexpectedReceived(); + } + + [Test] + public void EmptyText_DrawsNothing() + { + TextLayout.Layout("", CreateTestGlyphs(), TopLeft(WireframeCharacterWidth.Proportional, 0f), _geometry, null); + + Assert.That(_geometry.VertexCount, Is.Zero); + Assert.That(_geometry.EdgeCount, Is.Zero); + } + + [Test] + public void Buffers_GrowByDoublingAndShrinkOnceMostlyEmpty() + { + GlyphGeometry geometry = new(); + Assert.That(geometry.VertexCapacity, Is.EqualTo(16)); + + geometry.Begin(100, 10); + Assert.That(geometry.VertexCapacity, Is.EqualTo(128)); + geometry.Begin(40, 10); + Assert.That(geometry.VertexCapacity, Is.EqualTo(128)); + geometry.Begin(20, 10); + Assert.That(geometry.VertexCapacity, Is.EqualTo(64)); + } + + private WireframeGlyphs CreateTestGlyphs() + { + return CreateGlyphs(CreatePack(new[] + { + Character('A', Open(0.2f, Baseline, 0.8f, Baseline)), + Character('B', Closed(0.25f, Baseline, 0.75f, Baseline, 0.75f, Baseline + 0.5f, 0.25f, Baseline + 0.5f)), + Character('?', Open(0.4f, Baseline, 0.6f, Baseline)), + Character(' ') + })); + } + + /// Characters 1 unit tall at the top left of 10 by 2 bounds, kept on their lines. + private static TextSettings TopLeft(WireframeCharacterWidth width, float spacing) + { + TextSettings settings = TextSettings.Default; + settings.CharacterWidth = width; + settings.CharacterSpacing = spacing; + settings.Bounds = new Vector2(10f, 2f); + settings.HorizontalAlignment = WireframeHorizontalAlignment.Left; + settings.VerticalAlignment = WireframeVerticalAlignment.Top; + return settings; + } + } +} diff --git a/UnityPackage/Tests/Runtime/TextLayoutTests.cs.meta b/UnityPackage/Tests/Runtime/TextLayoutTests.cs.meta new file mode 100644 index 0000000..9105c2c --- /dev/null +++ b/UnityPackage/Tests/Runtime/TextLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67743d78af78570103e1808e7c2d2ebd From d9358039fcc0ffc6e3597fca5478ba1c205ccdcf Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:07:08 +0300 Subject: [PATCH 05/30] feature: text shape --- UnityPackage/Runtime/Core/GlyphShape.cs | 125 ++++++++ UnityPackage/Runtime/Core/GlyphShape.cs.meta | 2 + UnityPackage/Runtime/Shapes/Text/GlyphText.cs | 268 ++++++++++++++++++ .../Runtime/Shapes/Text/GlyphText.cs.meta | 2 + UnityPackage/Runtime/Shapes/Text/IText.cs | 68 +++++ .../Runtime/Shapes/Text/IText.cs.meta | 2 + .../Runtime/Shapes/Text/TextFactory.cs | 37 +++ .../Runtime/Shapes/Text/TextFactory.cs.meta | 2 + .../Runtime/Shapes/Text/TextSettings.cs | 20 ++ UnityPackage/Tests/Runtime/GlyphTestBase.cs | 19 ++ UnityPackage/Tests/Runtime/TextLayoutTests.cs | 18 +- UnityPackage/Tests/Runtime/TextTests.cs | 164 +++++++++++ UnityPackage/Tests/Runtime/TextTests.cs.meta | 2 + 13 files changed, 712 insertions(+), 17 deletions(-) create mode 100644 UnityPackage/Runtime/Core/GlyphShape.cs create mode 100644 UnityPackage/Runtime/Core/GlyphShape.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/GlyphText.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/GlyphText.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/IText.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/IText.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/TextFactory.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/TextFactory.cs.meta create mode 100644 UnityPackage/Tests/Runtime/TextTests.cs create mode 100644 UnityPackage/Tests/Runtime/TextTests.cs.meta diff --git a/UnityPackage/Runtime/Core/GlyphShape.cs b/UnityPackage/Runtime/Core/GlyphShape.cs new file mode 100644 index 0000000..83759a1 --- /dev/null +++ b/UnityPackage/Runtime/Core/GlyphShape.cs @@ -0,0 +1,125 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + /// + /// Base of the shapes drawn from glyphs: texts and symbols. They lay their glyphs out into a + /// , whose capacity is the vertex count they take in their chunk, so most edits rewrite + /// them where they are, and only outgrowing that capacity moves them. They lie in the XY plane of their rotation, read + /// from the -Z side. + /// + internal abstract class GlyphShape : RigidShape + { + private readonly GlyphGeometry _geometry; + private WireframeGlyphs _glyphs; + private int _layoutVersion; + + /// The shape's glyphs, already laid out by the derived constructor. + protected GlyphShape( + GlyphGeometry geometry, + WireframeGlyphs glyphs, + Transform bone, + Vector3 localPosition, + Quaternion localRotation) + : base(geometry.VertexCapacity, new EdgeSource(geometry.Pattern, geometry.EdgeCount), bone, localPosition, + localRotation) + { + _geometry = geometry; + _glyphs = glyphs; + _layoutVersion = GlyphEdits.Version; + } + + public WireframeGlyphs Glyphs + { + get + { + EnsureUsable(); + return _glyphs; + } + set + { + EnsureUsable(); + if (ReferenceEquals(value, _glyphs)) + { + return; + } + _glyphs = value; + Rebuild(true); + } + } + + /// The object that warnings about missing glyphs point to, such as the component that draws the shape. + internal Object WarningContext { get; set; } + + /// The glyphs the shape draws with: its own, or the package's default ones when it has none. + protected WireframeGlyphs DrawnGlyphs + { + get => DrawnGlyphsOf(_glyphs); + } + + /// Lays the glyphs out again if any glyph pack or glyph list changed since, as only happens in the Editor. + internal void RebuildIfStale() + { + if (_layoutVersion != GlyphEdits.Version) + { + Rebuild(true); + } + } + + /// + /// Replaces the glyphs, without laying them out, for derived shapes that set several fields at once before one + /// . Returns whether they changed. + /// + protected bool ReplaceGlyphs(WireframeGlyphs glyphs) + { + if (ReferenceEquals(glyphs, _glyphs)) + { + return false; + } + _glyphs = glyphs; + return true; + } + + /// + /// Lays the glyphs out again and queues what changed. Pass true for when the + /// glyphs drawn may differ, such as after the text or the glyph list changed, so the edges are replaced; other + /// changes, such as alignment, only move the points. + /// + protected void Rebuild(bool glyphsChanged) + { + _layoutVersion = GlyphEdits.Version; + int[] pattern = _geometry.Pattern; + Layout(_geometry, DrawnGlyphs); + if (_geometry.VertexCapacity != VertexCount) + { + // Outgrown or mostly empty: the host moves the shape to a block of the new size. + Resize(_geometry.VertexCapacity, new EdgeSource(_geometry.Pattern, _geometry.EdgeCount)); + return; + } + if (glyphsChanged || _geometry.EdgeCount != EdgeCount || !ReferenceEquals(pattern, _geometry.Pattern)) + { + ReplaceEdges(_geometry.Pattern, _geometry.EdgeCount); + } + MarkDirty(DirtyFlags.Positions); + } + + /// Writes the shape's glyphs into , in its own space. + protected abstract void Layout(GlyphGeometry geometry, WireframeGlyphs glyphs); + + protected sealed override void WriteShape(Span positions) + { + int count = _geometry.VertexCount; + _geometry.Points.AsSpan(0, count).CopyTo(positions); + // Points past the drawn ones belong to no edge, and zero keeps them finite. + positions.Slice(count).Clear(); + } + + /// The glyphs a shape given draws with: those, or the package's default ones. + protected static WireframeGlyphs DrawnGlyphsOf(WireframeGlyphs glyphs) + { + return glyphs != null ? glyphs : WireframeGlyphs.Default; + } + } +} diff --git a/UnityPackage/Runtime/Core/GlyphShape.cs.meta b/UnityPackage/Runtime/Core/GlyphShape.cs.meta new file mode 100644 index 0000000..780cd10 --- /dev/null +++ b/UnityPackage/Runtime/Core/GlyphShape.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 29f07ddc18e95ac051bdd04a6d0b2016 diff --git a/UnityPackage/Runtime/Shapes/Text/GlyphText.cs b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs new file mode 100644 index 0000000..a51cb03 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs @@ -0,0 +1,268 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + internal sealed class GlyphText : GlyphShape, IText + { + private char[] _characters; + private int _length; + // Created on first read after SetText, and kept until the text changes. + private string _text; + private TextSettings _settings; + + internal GlyphText( + Transform bone, + Vector3 localPosition, + Quaternion localRotation, + ReadOnlySpan text, + WireframeGlyphs glyphs, + in TextSettings settings) + : base(LaidOut(text, glyphs, settings), glyphs, bone, localPosition, localRotation) + { + _settings = settings; + _characters = new char[Math.Max(16, text.Length)]; + text.CopyTo(_characters); + _length = text.Length; + } + + public string Text + { + get + { + EnsureUsable(); + return _text ??= new string(_characters, 0, _length); + } + set + { + value ??= string.Empty; + SetText(value); + _text = value; + } + } + + public WireframeCharacterWidth CharacterWidth + { + get + { + EnsureUsable(); + return _settings.CharacterWidth; + } + set + { + EnsureUsable(); + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, $"{nameof(CharacterWidth)} isn't a {nameof(WireframeCharacterWidth)} value."); + } + if (value != _settings.CharacterWidth) + { + _settings.CharacterWidth = value; + Rebuild(false); + } + } + } + + public float CharacterSize + { + get + { + EnsureUsable(); + return _settings.CharacterSize; + } + set + { + EnsureUsable(); + if (value != _settings.CharacterSize) + { + _settings.CharacterSize = value; + Rebuild(false); + } + } + } + + public float CharacterSpacing + { + get + { + EnsureUsable(); + return _settings.CharacterSpacing; + } + set + { + EnsureUsable(); + if (value != _settings.CharacterSpacing) + { + _settings.CharacterSpacing = value; + Rebuild(false); + } + } + } + + public float LineSpacing + { + get + { + EnsureUsable(); + return _settings.LineSpacing; + } + set + { + EnsureUsable(); + if (value != _settings.LineSpacing) + { + _settings.LineSpacing = value; + Rebuild(false); + } + } + } + + public Vector2 Bounds + { + get + { + EnsureUsable(); + return _settings.Bounds; + } + set + { + EnsureUsable(); + if (value != _settings.Bounds) + { + _settings.Bounds = value; + Rebuild(false); + } + } + } + + public WireframeHorizontalAlignment HorizontalAlignment + { + get + { + EnsureUsable(); + return _settings.HorizontalAlignment; + } + set + { + EnsureUsable(); + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, + $"{nameof(HorizontalAlignment)} isn't a {nameof(WireframeHorizontalAlignment)} value."); + } + if (value != _settings.HorizontalAlignment) + { + _settings.HorizontalAlignment = value; + Rebuild(false); + } + } + } + + public WireframeVerticalAlignment VerticalAlignment + { + get + { + EnsureUsable(); + return _settings.VerticalAlignment; + } + set + { + EnsureUsable(); + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, + $"{nameof(VerticalAlignment)} isn't a {nameof(WireframeVerticalAlignment)} value."); + } + if (value != _settings.VerticalAlignment) + { + _settings.VerticalAlignment = value; + Rebuild(false); + } + } + } + + public WireframeTextOverflow Overflow + { + get + { + EnsureUsable(); + return _settings.Overflow; + } + set + { + EnsureUsable(); + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, $"{nameof(Overflow)} isn't a {nameof(WireframeTextOverflow)} value."); + } + if (value != _settings.Overflow) + { + _settings.Overflow = value; + Rebuild(false); + } + } + } + + public void SetText(ReadOnlySpan text) + { + EnsureUsable(); + if (text.SequenceEqual(new ReadOnlySpan(_characters, 0, _length))) + { + return; + } + CopyText(text); + Rebuild(true); + } + + /// + /// Sets the text, the glyphs and every setting at once and lays them out once, for components, which write all + /// their fields together. Settings are taken as they are, already checked. + /// + internal void Apply(ReadOnlySpan text, WireframeGlyphs glyphs, in TextSettings settings) + { + EnsureUsable(); + bool glyphsChanged = ReplaceGlyphs(glyphs); + if (!text.SequenceEqual(new ReadOnlySpan(_characters, 0, _length))) + { + CopyText(text); + glyphsChanged = true; + } + _settings = settings; + Rebuild(glyphsChanged); + } + + protected override void Layout(GlyphGeometry geometry, WireframeGlyphs glyphs) + { + TextLayout.Layout(new ReadOnlySpan(_characters, 0, _length), glyphs, _settings, geometry, WarningContext); + } + + protected override void ScaleSizes(float factor) + { + _settings.CharacterSize *= factor; + _settings.Bounds *= factor; + Rebuild(false); + } + + private void CopyText(ReadOnlySpan text) + { + if (text.Length > _characters.Length) + { + _characters = new char[Math.Max(text.Length, _characters.Length * 2)]; + } + text.CopyTo(_characters); + _length = text.Length; + _text = null; + } + + private static GlyphGeometry LaidOut(ReadOnlySpan text, WireframeGlyphs glyphs, in TextSettings settings) + { + GlyphGeometry geometry = new(); + TextLayout.Layout(text, DrawnGlyphsOf(glyphs), settings, geometry, null); + return geometry; + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/GlyphText.cs.meta b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs.meta new file mode 100644 index 0000000..5fca900 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df2a48cba44c30021c9fe909fac7b66d diff --git a/UnityPackage/Runtime/Shapes/Text/IText.cs b/UnityPackage/Runtime/Shapes/Text/IText.cs new file mode 100644 index 0000000..5432db4 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/IText.cs @@ -0,0 +1,68 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Text drawn with the glyphs of a . It lies in the XY plane of its rotation and reads + /// from the -Z side, so with no rotation it faces a camera looking along +Z. Its lines are laid out in + /// , centered on its position: each line by , and all of them + /// together by . Characters the glyphs lack are drawn as '?', with one warning each. + /// Changing the text allocates nothing once its buffers have grown to fit, so it can change every frame. + /// + public interface IText : IRigidShape + { + /// + /// The characters drawn, with \n starting a new line, a tab taking 4 spaces, and other control characters + /// skipped. Reading it after creates the string once. + /// + string Text { get; set; } + + /// + /// The glyphs to draw with, or null, the default, for . + /// + WireframeGlyphs Glyphs { get; set; } + + /// + /// How far each character moves the next one along. by + /// default. + /// + /// The value isn't a . + WireframeCharacterWidth CharacterWidth { get; set; } + + /// Height of a glyph box in the bone's units. 1 by default. + float CharacterSize { get; set; } + + /// Extra room after each character, in glyph boxes. 0.1 by default. + float CharacterSpacing { get; set; } + + /// Extra room between lines, in glyph boxes. 0 by default, so lines are one glyph box apart. + float LineSpacing { get; set; } + + /// + /// Width and height of the rectangle the text is aligned in, centered on its position, in the bone's units. 4 by + /// 1 by default. + /// + Vector2 Bounds { get; set; } + + /// Where each line sits across the bounds. Centered by default. + /// The value isn't a . + WireframeHorizontalAlignment HorizontalAlignment { get; set; } + + /// Where the lines sit between the top and the bottom of the bounds. Centered by default. + /// The value isn't a . + WireframeVerticalAlignment VerticalAlignment { get; set; } + + /// + /// What lines wider than the bounds do: run past them, the default, or wrap onto more lines. + /// + /// The value isn't a . + WireframeTextOverflow Overflow { get; set; } + + /// + /// Sets the characters drawn without allocating, such as a number written into a stackalloc buffer with + /// TryFormat. Text equal to the current one changes nothing. + /// + void SetText(ReadOnlySpan text); + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/IText.cs.meta b/UnityPackage/Runtime/Shapes/Text/IText.cs.meta new file mode 100644 index 0000000..41bf5b7 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/IText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6e1496e5fa988ee566bc91fb3db8bba diff --git a/UnityPackage/Runtime/Shapes/Text/TextFactory.cs b/UnityPackage/Runtime/Shapes/Text/TextFactory.cs new file mode 100644 index 0000000..b8dcf40 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextFactory.cs @@ -0,0 +1,37 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates texts in a , drawn with . + public static class TextFactory + { + /// + /// Creates white text centered on the world origin, in the XY plane and read from the -Z side, with characters 1 + /// unit tall. + /// + public static IText CreateText(this WireframeContainer container, string text) + { + return CreateText(container, null, Vector3.zero, Quaternion.identity, text); + } + + /// + /// Creates white text centered on a world position, in the XY plane of and read from + /// its -Z side, with characters 1 unit tall. + /// + public static IText CreateText(this WireframeContainer container, Vector3 position, Quaternion rotation, string text) + { + return CreateText(container, null, position, rotation, text); + } + + /// + /// Creates white text that follows , centered on a position in the bone's local space, in + /// the XY plane of and read from its -Z side, with characters 1 bone unit tall. + /// + public static IText CreateText( + this WireframeContainer container, Transform bone, Vector3 localPosition, Quaternion localRotation, string text) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new GlyphText(bone, localPosition, localRotation, text, null, TextSettings.Default)); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/TextFactory.cs.meta b/UnityPackage/Runtime/Shapes/Text/TextFactory.cs.meta new file mode 100644 index 0000000..f6dac52 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/TextFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f43ec42a14192b99781a4ec59bdd4900 diff --git a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs index 789ed0c..0308618 100644 --- a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs +++ b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs @@ -45,5 +45,25 @@ internal static TextSettings Default Overflow = WireframeTextOverflow.Overflow }; } + + internal static bool IsDefined(WireframeCharacterWidth width) + { + return width is WireframeCharacterWidth.Proportional or WireframeCharacterWidth.Monospace; + } + + internal static bool IsDefined(WireframeHorizontalAlignment alignment) + { + return alignment is >= WireframeHorizontalAlignment.Left and <= WireframeHorizontalAlignment.Right; + } + + internal static bool IsDefined(WireframeVerticalAlignment alignment) + { + return alignment is >= WireframeVerticalAlignment.Top and <= WireframeVerticalAlignment.Bottom; + } + + internal static bool IsDefined(WireframeTextOverflow overflow) + { + return overflow is WireframeTextOverflow.Overflow or WireframeTextOverflow.Wrap; + } } } diff --git a/UnityPackage/Tests/Runtime/GlyphTestBase.cs b/UnityPackage/Tests/Runtime/GlyphTestBase.cs index a432375..e7aaf7a 100644 --- a/UnityPackage/Tests/Runtime/GlyphTestBase.cs +++ b/UnityPackage/Tests/Runtime/GlyphTestBase.cs @@ -5,6 +5,25 @@ namespace reromanlee.Wireframes.Tests /// Builds glyph packs and glyph lists in code, with glyphs simple enough to check by hand. public abstract class GlyphTestBase : WireframesTestBase { + private protected const float Baseline = WireframeGlyphPack.Baseline; + + /// + /// Glyphs simple enough to check by hand: A is a line from 0.2 to 0.8 on the baseline, B a closed square from + /// 0.25 to 0.75, ? a line from 0.4 to 0.6, and the space has no lines. The Heart symbol is a closed triangle. + /// + private protected WireframeGlyphs CreateTestGlyphs() + { + return CreateGlyphs(CreatePack( + new[] + { + Character('A', Open(0.2f, Baseline, 0.8f, Baseline)), + Character('B', Closed(0.25f, Baseline, 0.75f, Baseline, 0.75f, Baseline + 0.5f, 0.25f, Baseline + 0.5f)), + Character('?', Open(0.4f, Baseline, 0.6f, Baseline)), + Character(' ') + }, + new[] { Symbol("Heart", Closed(0f, 0f, 1f, 0f, 0.5f, 1f)) })); + } + private protected WireframeGlyphPack CreatePack(CharacterGlyph[] characters, SymbolGlyph[] symbols = null) { WireframeGlyphPack pack = Track(ScriptableObject.CreateInstance()); diff --git a/UnityPackage/Tests/Runtime/TextLayoutTests.cs b/UnityPackage/Tests/Runtime/TextLayoutTests.cs index c1fea58..73979f9 100644 --- a/UnityPackage/Tests/Runtime/TextLayoutTests.cs +++ b/UnityPackage/Tests/Runtime/TextLayoutTests.cs @@ -5,14 +5,9 @@ namespace reromanlee.Wireframes.Tests { - /// - /// Laying text out into points and edges. The glyphs are simple: A is a line from 0.2 to 0.8 on the baseline, B a - /// closed square from 0.25 to 0.75, and ? a line from 0.4 to 0.6. - /// + /// Laying text out into points and edges, with the glyphs of . public class TextLayoutTests : GlyphTestBase { - private const float Baseline = WireframeGlyphPack.Baseline; - private readonly GlyphGeometry _geometry = new(); [Test] @@ -198,17 +193,6 @@ public void Buffers_GrowByDoublingAndShrinkOnceMostlyEmpty() Assert.That(geometry.VertexCapacity, Is.EqualTo(64)); } - private WireframeGlyphs CreateTestGlyphs() - { - return CreateGlyphs(CreatePack(new[] - { - Character('A', Open(0.2f, Baseline, 0.8f, Baseline)), - Character('B', Closed(0.25f, Baseline, 0.75f, Baseline, 0.75f, Baseline + 0.5f, 0.25f, Baseline + 0.5f)), - Character('?', Open(0.4f, Baseline, 0.6f, Baseline)), - Character(' ') - })); - } - /// Characters 1 unit tall at the top left of 10 by 2 bounds, kept on their lines. private static TextSettings TopLeft(WireframeCharacterWidth width, float spacing) { diff --git a/UnityPackage/Tests/Runtime/TextTests.cs b/UnityPackage/Tests/Runtime/TextTests.cs new file mode 100644 index 0000000..541e0a3 --- /dev/null +++ b/UnityPackage/Tests/Runtime/TextTests.cs @@ -0,0 +1,164 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools.Constraints; +using Is = UnityEngine.TestTools.Constraints.Is; + +namespace reromanlee.Wireframes.Tests +{ + /// Texts created in containers, drawn with the glyphs of . + public class TextTests : GlyphTestBase + { + [Test] + public void Text_LiesInTheXYPlaneOfItsBone() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(1f, 2f, 3f), Quaternion.Euler(0f, 90f, 0f)); + + IText text = CreateTestText(container, "A", bone); + + // A runs from -0.3 to 0.3 across the text, 0.25 below its center, and the bone turns +X to -Z. + (Vector3 A, Vector3 B) edge = BakeEdges(container, text)[0]; + AssertApproximately(new Vector3(1f, 1.75f, 3.3f), edge.A); + AssertApproximately(new Vector3(1f, 1.75f, 2.7f), edge.B); + } + + [Test] + public void ChangedText_IsRewrittenWhereItIs() + { + WireframeContainer container = CreateContainer(); + ILine before = container.CreateLine(Vector3.zero, Vector3.one); + IText text = CreateTestText(container, "A"); + ILine after = container.CreateLine(Vector3.one, Vector3.one * 2f); + container.Proxy.Flush(); + int vertexStart = ((Shape)text).VertexStart; + + text.Text = "AB"; + + Assert.That(((Shape)text).VertexStart, Is.EqualTo(vertexStart)); + Assert.That(BakeEdges(container, text), Has.Length.EqualTo(1 + 4)); + Assert.That(ChunkOf(text).Mesh.GetIndices(0), Has.Length.EqualTo((1 + 1 + 4 + 1) * 2)); + AssertApproximately(Vector3.one, BakeEdges(container, before)[0].B); + AssertApproximately(Vector3.one * 2f, BakeEdges(container, after)[0].B); + } + + [Test] + public void TextThatOutgrowsItsRoom_MovesAndKeepsDrawing() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + Assert.That(((Shape)text).VertexCount, Is.EqualTo(16)); + + text.Text = new string('B', 20); + + Assert.That(((Shape)text).VertexCount, Is.EqualTo(128)); + Assert.That(BakeEdges(container, text), Has.Length.EqualTo(20 * 4)); + + text.Text = "A"; + + Assert.That(((Shape)text).VertexCount, Is.EqualTo(16)); + Assert.That(BakeEdges(container, text), Has.Length.EqualTo(1)); + } + + [Test] + public void SettingTheTextEveryFrame_AllocatesNothing() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + char[] characters = { 'A', 'B', 'B', 'A', 'B' }; + + void ChangeTheTextBackAndForth() + { + text.SetText(characters.AsSpan(0, 5)); + container.Proxy.Flush(); + text.SetText(characters.AsSpan(0, 2)); + container.Proxy.Flush(); + } + + // Warms up every path, and lets each buffer grow once to what the texts need. + ChangeTheTextBackAndForth(); + ChangeTheTextBackAndForth(); + + Assert.That(ChangeTheTextBackAndForth, Is.Not.AllocatingGCMemory()); + } + + [Test] + public void Settings_MoveThePointsAndKeepTheEdges() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + container.Proxy.Flush(); + int firstSlot = ((Shape)text).GetEdgeSlot(0); + + text.HorizontalAlignment = WireframeHorizontalAlignment.Left; + text.VerticalAlignment = WireframeVerticalAlignment.Top; + text.CharacterSize = 0.5f; + + (Vector3 A, Vector3 B) edge = BakeEdges(container, text)[0]; + Assert.That(((Shape)text).GetEdgeSlot(0), Is.EqualTo(firstSlot)); + // The default bounds are 4 by 1, so the top left corner is at (-2, 0.5). + AssertApproximately(new Vector3(-2f, 0.5f - 0.5f + Baseline * 0.5f, 0f), edge.A); + AssertApproximately(new Vector3(-2f + 0.3f, 0.5f - 0.5f + Baseline * 0.5f, 0f), edge.B); + } + + [Test] + public void TextRead_ReturnsWhatWasSetAndCreatesItOnce() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "AB"); + Assert.That(text.Text, Is.EqualTo("AB")); + + text.SetText("BA".AsSpan()); + + string read = text.Text; + Assert.That(read, Is.EqualTo("BA")); + Assert.That(text.Text, Is.SameAs(read)); + text.Text = null; + Assert.That(text.Text, Is.Empty); + } + + [Test] + public void NewBone_KeepsTheSizeOfTheTextInTheWorld() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + Transform scaled = CreateBone(Vector3.zero, Quaternion.identity, 2f); + + text.Bone = scaled; + + AssertApproximately(0.5f, text.CharacterSize); + AssertApproximately(2f, text.Bounds.x); + AssertApproximately(new Vector3(0.3f, -0.25f, 0f), BakeEdges(container, text)[0].B); + } + + [Test] + public void ValuesOutsideTheirEnums_Throw() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + + Assert.Throws(() => text.CharacterWidth = (WireframeCharacterWidth)2); + Assert.Throws(() => text.HorizontalAlignment = (WireframeHorizontalAlignment)3); + Assert.Throws(() => text.VerticalAlignment = (WireframeVerticalAlignment)(-1)); + Assert.Throws(() => text.Overflow = (WireframeTextOverflow)2); + } + + [Test] + public void DisposedText_Throws() + { + WireframeContainer container = CreateContainer(); + IText text = CreateTestText(container, "A"); + text.Dispose(); + + Assert.Throws(() => text.Text = "B"); + Assert.Throws(() => text.SetText("B".AsSpan())); + Assert.Throws(() => _ = text.Glyphs); + } + + private IText CreateTestText(WireframeContainer container, string text, Transform bone = null) + { + return container.Proxy.Add(new GlyphText( + bone, Vector3.zero, Quaternion.identity, text, CreateTestGlyphs(), TextSettings.Default)); + } + } +} diff --git a/UnityPackage/Tests/Runtime/TextTests.cs.meta b/UnityPackage/Tests/Runtime/TextTests.cs.meta new file mode 100644 index 0000000..b1dfcaf --- /dev/null +++ b/UnityPackage/Tests/Runtime/TextTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d7920b20b717c284da99a8c6d24c96a4 From e61c60a417cc96814182c99a16ca982024bf4ed6 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:09:25 +0300 Subject: [PATCH 06/30] feature: symbol shape --- UnityPackage/Runtime/Glyphs/SymbolKeys.cs | 25 +++- UnityPackage/Runtime/Shapes/Symbol.meta | 8 ++ .../Runtime/Shapes/Symbol/GlyphSymbol.cs | 132 ++++++++++++++++++ .../Runtime/Shapes/Symbol/GlyphSymbol.cs.meta | 2 + UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs | 34 +++++ .../Runtime/Shapes/Symbol/ISymbol.cs.meta | 2 + .../Runtime/Shapes/Symbol/SymbolFactory.cs | 49 +++++++ .../Shapes/Symbol/SymbolFactory.cs.meta | 2 + UnityPackage/Tests/Runtime/GlyphTestBase.cs | 5 +- UnityPackage/Tests/Runtime/SymbolTests.cs | 113 +++++++++++++++ .../Tests/Runtime/SymbolTests.cs.meta | 2 + 11 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 UnityPackage/Runtime/Shapes/Symbol.meta create mode 100644 UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs create mode 100644 UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs create mode 100644 UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs create mode 100644 UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs.meta create mode 100644 UnityPackage/Tests/Runtime/SymbolTests.cs create mode 100644 UnityPackage/Tests/Runtime/SymbolTests.cs.meta diff --git a/UnityPackage/Runtime/Glyphs/SymbolKeys.cs b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs index 8a19054..e40d8a0 100644 --- a/UnityPackage/Runtime/Glyphs/SymbolKeys.cs +++ b/UnityPackage/Runtime/Glyphs/SymbolKeys.cs @@ -46,15 +46,18 @@ internal static int Of(string keyword) /// doesn't have int values. internal static int Of(TSymbol symbol) where TSymbol : unmanaged, Enum { - if (UnsafeUtility.SizeOf() != sizeof(int)) - { - throw new ArgumentException( - $"{typeof(TSymbol).Name} isn't a symbol enum. Symbol enums have int values, like the ones the " - + "Glyph Editor generates.", nameof(symbol)); - } + CheckSymbolEnum(nameof(symbol)); return UnsafeUtility.As(ref symbol); } + /// The member of a generated symbol enum whose value is , made without boxing. + /// doesn't have int values. + internal static TSymbol ToSymbol(int key) where TSymbol : unmanaged, Enum + { + CheckSymbolEnum("TSymbol"); + return UnsafeUtility.As(ref key); + } + /// /// Returns why can't name a symbol, or null when it can: it has to be a C# identifier /// of ASCII letters, digits and underscores, not a C# keyword, not None, and not hash to the key of no symbol. @@ -89,5 +92,15 @@ private static bool IsIdentifierStart(char character) { return character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or '_'; } + + private static void CheckSymbolEnum(string parameterName) where TSymbol : unmanaged, Enum + { + if (UnsafeUtility.SizeOf() != sizeof(int)) + { + throw new ArgumentException( + $"{typeof(TSymbol).Name} isn't a symbol enum. Symbol enums have int values, like the ones the " + + "Glyph Editor generates.", parameterName); + } + } } } diff --git a/UnityPackage/Runtime/Shapes/Symbol.meta b/UnityPackage/Runtime/Shapes/Symbol.meta new file mode 100644 index 0000000..ea23e35 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1df5343c85c863c922b98c8f7c5bb0f6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs new file mode 100644 index 0000000..fc12391 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs @@ -0,0 +1,132 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + internal sealed class GlyphSymbol : GlyphShape, ISymbol + { + private const char Fallback = '?'; + + private int _key; + private float _size; + + internal GlyphSymbol( + Transform bone, + Vector3 localPosition, + Quaternion localRotation, + int key, + WireframeGlyphs glyphs, + float size) + : base(LaidOut(key, glyphs, size), glyphs, bone, localPosition, localRotation) + { + _key = key; + _size = size; + } + + public float Size + { + get + { + EnsureUsable(); + return _size; + } + set + { + EnsureUsable(); + if (value != _size) + { + _size = value; + Rebuild(false); + } + } + } + + /// The hash of the drawn symbol's keyword, the value of its member in generated enums. + internal int SymbolKey + { + get + { + EnsureUsable(); + return _key; + } + set + { + EnsureUsable(); + if (value != _key) + { + _key = value; + Rebuild(true); + } + } + } + + public void SetSymbol(TSymbol symbol) where TSymbol : unmanaged, Enum + { + SymbolKey = SymbolKeys.Of(symbol); + } + + public TSymbol GetSymbol() where TSymbol : unmanaged, Enum + { + EnsureUsable(); + return SymbolKeys.ToSymbol(_key); + } + + /// Sets the symbol, the glyphs and the size at once and lays them out once, for components. + internal void Apply(int key, WireframeGlyphs glyphs, float size) + { + EnsureUsable(); + bool glyphsChanged = ReplaceGlyphs(glyphs) | key != _key; + _key = key; + _size = size; + Rebuild(glyphsChanged); + } + + protected override void Layout(GlyphGeometry geometry, WireframeGlyphs glyphs) + { + WriteGlyph(_key, glyphs, _size, geometry, WarningContext); + } + + protected override void ScaleSizes(float factor) + { + _size *= factor; + Rebuild(false); + } + + private static GlyphGeometry LaidOut(int key, WireframeGlyphs glyphs, float size) + { + GlyphGeometry geometry = new(); + WriteGlyph(key, DrawnGlyphsOf(glyphs), size, geometry, null); + return geometry; + } + + /// Writes the symbol's glyph with its box centered on the origin, or '?' when the glyphs lack it. + private static void WriteGlyph(int key, WireframeGlyphs glyphs, float size, GlyphGeometry geometry, Object context) + { + if (key == SymbolKeys.None || !TryFindGlyph(key, glyphs, context, out ResolvedGlyph glyph)) + { + geometry.Begin(0, 0); + return; + } + geometry.Begin(glyph.Metrics.VertexCount, glyph.Metrics.EdgeCount); + float half = size * 0.5f; + geometry.AddGlyph(glyph.Strokes, new Vector2(-half, -half), size); + } + + private static bool TryFindGlyph(int key, WireframeGlyphs glyphs, Object context, out ResolvedGlyph glyph) + { + if (glyphs == null) + { + GlyphWarnings.ReportMissingDefault(context); + glyph = default; + return false; + } + if (glyphs.TryResolveSymbol(key, out glyph)) + { + return true; + } + GlyphWarnings.ReportMissingSymbol(glyphs, key, context); + return glyphs.TryResolveCharacter(Fallback, out glyph); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs.meta b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs.meta new file mode 100644 index 0000000..3cceb95 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 287f7063897ce9f58f040e7cdec9b867 diff --git a/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs b/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs new file mode 100644 index 0000000..c95418a --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs @@ -0,0 +1,34 @@ +using System; + +namespace reromanlee.Wireframes +{ + /// + /// A symbol drawn with a glyph of a , named by a member of the enum the Glyph Editor + /// generates for a glyph pack. Its glyph box, across, is centered on its position and lies in the + /// XY plane of its rotation, read from the -Z side. A symbol the glyphs lack is drawn as '?', with one warning. + /// + public interface ISymbol : IRigidShape + { + /// + /// The glyphs to draw with, or null, the default, for . + /// + WireframeGlyphs Glyphs { get; set; } + + /// Width and height of the glyph box in the bone's units. 1 by default. + float Size { get; set; } + + /// + /// Draws , a member of a generated symbol enum. A symbol of the same name from any pack + /// of the glyphs counts, and the enum's None member draws nothing. + /// + /// doesn't have int values. + void SetSymbol(TSymbol symbol) where TSymbol : unmanaged, Enum; + + /// + /// Returns the symbol drawn as a member of . A symbol set from another pack's enum + /// comes back as the member of the same name, or as an undefined value when this enum has none. + /// + /// doesn't have int values. + TSymbol GetSymbol() where TSymbol : unmanaged, Enum; + } +} diff --git a/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs.meta b/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs.meta new file mode 100644 index 0000000..36366af --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/ISymbol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1bad67bf5fb7208dbb3041ed8dd4d0f3 diff --git a/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs b/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs new file mode 100644 index 0000000..a9fc935 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs @@ -0,0 +1,49 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Creates symbols in a , named by members of the enums the Glyph Editor generates + /// for glyph packs, and drawn with . + /// + public static class SymbolFactory + { + /// + /// Creates a white symbol 1 unit across, centered on the world origin, in the XY plane and read from the -Z side. + /// + /// doesn't have int values. + public static ISymbol CreateSymbol(this WireframeContainer container, TSymbol symbol) + where TSymbol : unmanaged, Enum + { + return CreateSymbol(container, null, Vector3.zero, Quaternion.identity, symbol); + } + + /// + /// Creates a white symbol 1 unit across, centered on a world position, in the XY plane of + /// and read from its -Z side. + /// + /// doesn't have int values. + public static ISymbol CreateSymbol( + this WireframeContainer container, Vector3 position, Quaternion rotation, TSymbol symbol) + where TSymbol : unmanaged, Enum + { + return CreateSymbol(container, null, position, rotation, symbol); + } + + /// + /// Creates a white symbol 1 bone unit across that follows , centered on a position in the + /// bone's local space, in the XY plane of and read from its -Z side. + /// + /// doesn't have int values. + public static ISymbol CreateSymbol( + this WireframeContainer container, Transform bone, Vector3 localPosition, Quaternion localRotation, + TSymbol symbol) + where TSymbol : unmanaged, Enum + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + int key = SymbolKeys.Of(symbol); + return proxy.Add(new GlyphSymbol(bone, localPosition, localRotation, key, null, ShapeDefaults.Size)); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs.meta b/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs.meta new file mode 100644 index 0000000..89ddba1 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/SymbolFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6f12c1a13aff9563689bd0d722be53c6 diff --git a/UnityPackage/Tests/Runtime/GlyphTestBase.cs b/UnityPackage/Tests/Runtime/GlyphTestBase.cs index e7aaf7a..d9d8907 100644 --- a/UnityPackage/Tests/Runtime/GlyphTestBase.cs +++ b/UnityPackage/Tests/Runtime/GlyphTestBase.cs @@ -9,7 +9,8 @@ public abstract class GlyphTestBase : WireframesTestBase /// /// Glyphs simple enough to check by hand: A is a line from 0.2 to 0.8 on the baseline, B a closed square from - /// 0.25 to 0.75, ? a line from 0.4 to 0.6, and the space has no lines. The Heart symbol is a closed triangle. + /// 0.25 to 0.75, ? a line from 0.4 to 0.6, and the space has no lines. The Heart symbol is a closed triangle + /// filling the box, and the Star symbol a line across its middle. /// private protected WireframeGlyphs CreateTestGlyphs() { @@ -21,7 +22,7 @@ private protected WireframeGlyphs CreateTestGlyphs() Character('?', Open(0.4f, Baseline, 0.6f, Baseline)), Character(' ') }, - new[] { Symbol("Heart", Closed(0f, 0f, 1f, 0f, 0.5f, 1f)) })); + new[] { Symbol("Heart", Closed(0f, 0f, 1f, 0f, 0.5f, 1f)), Symbol("Star", Open(0f, 0.5f, 1f, 0.5f)) })); } private protected WireframeGlyphPack CreatePack(CharacterGlyph[] characters, SymbolGlyph[] symbols = null) diff --git a/UnityPackage/Tests/Runtime/SymbolTests.cs b/UnityPackage/Tests/Runtime/SymbolTests.cs new file mode 100644 index 0000000..364d5f3 --- /dev/null +++ b/UnityPackage/Tests/Runtime/SymbolTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Text.RegularExpressions; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace reromanlee.Wireframes.Tests +{ + /// Symbols created in containers, drawn with the glyphs of . + public class SymbolTests : GlyphTestBase + { + private enum TestSymbols + { + None = 0, + Heart = -1226833789, + Star = 1365736337, + Moon = 42 + } + + private enum NarrowSymbols : byte + { + None + } + + [Test] + public void Symbol_IsCenteredOnItsPositionInItsXYPlane() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(1f, 0f, 0f), Quaternion.identity); + + ISymbol symbol = CreateTestSymbol(container, TestSymbols.Heart, bone); + symbol.Size = 2f; + + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, symbol); + Assert.That(edges, Has.Length.EqualTo(3)); + AssertApproximately(new Vector3(0f, -1f, 0f), edges[0].A); + AssertApproximately(new Vector3(2f, -1f, 0f), edges[1].A); + AssertApproximately(new Vector3(1f, 1f, 0f), edges[2].A); + AssertApproximately(new Vector3(0f, -1f, 0f), edges[2].B); + } + + [Test] + public void ChangedSymbol_IsDrawnInstead() + { + WireframeContainer container = CreateContainer(); + ISymbol symbol = CreateTestSymbol(container, TestSymbols.Heart); + + symbol.SetSymbol(TestSymbols.Star); + + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, symbol); + Assert.That(edges, Has.Length.EqualTo(1)); + AssertApproximately(new Vector3(-0.5f, 0f, 0f), edges[0].A); + AssertApproximately(new Vector3(0.5f, 0f, 0f), edges[0].B); + Assert.That(symbol.GetSymbol(), Is.EqualTo(TestSymbols.Star)); + } + + [Test] + public void MissingSymbol_IsDrawnAsAQuestionMarkWithOneWarning() + { + WireframeContainer container = CreateContainer(); + LogAssert.Expect(LogType.Warning, new Regex("A symbol isn't in the glyphs")); + + ISymbol symbol = CreateTestSymbol(container, TestSymbols.Heart); + symbol.SetSymbol(TestSymbols.Moon); + symbol.Size = 2f; + + LogAssert.NoUnexpectedReceived(); + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, symbol); + Assert.That(edges, Has.Length.EqualTo(1)); + AssertApproximately(new Vector3(-0.2f, -0.5f, 0f), edges[0].A); + } + + [Test] + public void NoneSymbol_DrawsNothingWithoutWarning() + { + WireframeContainer container = CreateContainer(); + + ISymbol symbol = CreateTestSymbol(container, TestSymbols.None); + + Assert.That(BakeEdges(container, symbol), Is.Empty); + LogAssert.NoUnexpectedReceived(); + } + + [Test] + public void NewBone_KeepsTheSizeOfTheSymbolInTheWorld() + { + WireframeContainer container = CreateContainer(); + ISymbol symbol = CreateTestSymbol(container, TestSymbols.Star); + Transform scaled = CreateBone(Vector3.zero, Quaternion.identity, 2f); + + symbol.Bone = scaled; + + AssertApproximately(0.5f, symbol.Size); + AssertApproximately(new Vector3(0.5f, 0f, 0f), BakeEdges(container, symbol)[0].B); + } + + [Test] + public void EnumsWithoutIntValues_AreRejected() + { + WireframeContainer container = CreateContainer(); + ISymbol symbol = CreateTestSymbol(container, TestSymbols.Heart); + + Assert.Throws(() => symbol.SetSymbol(NarrowSymbols.None)); + Assert.Throws(() => container.CreateSymbol(NarrowSymbols.None)); + } + + private ISymbol CreateTestSymbol(WireframeContainer container, TestSymbols symbol, Transform bone = null) + { + return container.Proxy.Add(new GlyphSymbol( + bone, Vector3.zero, Quaternion.identity, SymbolKeys.Of(symbol), CreateTestGlyphs(), ShapeDefaults.Size)); + } + } +} diff --git a/UnityPackage/Tests/Runtime/SymbolTests.cs.meta b/UnityPackage/Tests/Runtime/SymbolTests.cs.meta new file mode 100644 index 0000000..59e6ed4 --- /dev/null +++ b/UnityPackage/Tests/Runtime/SymbolTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a831a53b9c1b51b1185570528ad003fb From 1a0a18e0a4fb6b9b436fa8383ec42d616d534aec Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:15:44 +0300 Subject: [PATCH 07/30] feature: glyph components --- .../Runtime/Components/SymbolKeyAttribute.cs | 12 + .../Components/SymbolKeyAttribute.cs.meta | 2 + .../Components/WireframeCenteredShape.cs | 5 +- .../Runtime/Components/WireframeShape.cs | 11 +- UnityPackage/Runtime/Core/GlyphShape.cs | 20 +- .../Runtime/Shapes/Symbol/GlyphSymbol.cs | 21 +- .../Runtime/Shapes/Symbol/WireframeSymbol.cs | 115 ++++++ .../Shapes/Symbol/WireframeSymbol.cs.meta | 2 + UnityPackage/Runtime/Shapes/Text/GlyphText.cs | 21 +- .../Runtime/Shapes/Text/TextSettings.cs | 22 +- .../Runtime/Shapes/Text/WireframeText.cs | 350 ++++++++++++++++++ .../Runtime/Shapes/Text/WireframeText.cs.meta | 2 + .../Tests/Editor/GlyphEditModeTests.cs | 92 +++++ .../Tests/Editor/GlyphEditModeTests.cs.meta | 2 + .../Tests/Runtime/GlyphComponentTests.cs | 159 ++++++++ .../Tests/Runtime/GlyphComponentTests.cs.meta | 2 + 16 files changed, 820 insertions(+), 18 deletions(-) create mode 100644 UnityPackage/Runtime/Components/SymbolKeyAttribute.cs create mode 100644 UnityPackage/Runtime/Components/SymbolKeyAttribute.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs create mode 100644 UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs.meta create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeText.cs create mode 100644 UnityPackage/Runtime/Shapes/Text/WireframeText.cs.meta create mode 100644 UnityPackage/Tests/Editor/GlyphEditModeTests.cs create mode 100644 UnityPackage/Tests/Editor/GlyphEditModeTests.cs.meta create mode 100644 UnityPackage/Tests/Runtime/GlyphComponentTests.cs create mode 100644 UnityPackage/Tests/Runtime/GlyphComponentTests.cs.meta diff --git a/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs b/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs new file mode 100644 index 0000000..0a55dbb --- /dev/null +++ b/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Shows an int field that holds a symbol's key as a choice between the symbols of the component's glyphs, listed by + /// keyword, so the keyword is never saved as a string. + /// + internal sealed class SymbolKeyAttribute : PropertyAttribute + { + } +} diff --git a/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs.meta b/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs.meta new file mode 100644 index 0000000..f1a71a0 --- /dev/null +++ b/UnityPackage/Runtime/Components/SymbolKeyAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c7a4c30b6fc585540fcf8717960e61e diff --git a/UnityPackage/Runtime/Components/WireframeCenteredShape.cs b/UnityPackage/Runtime/Components/WireframeCenteredShape.cs index a701032..0d70e11 100644 --- a/UnityPackage/Runtime/Components/WireframeCenteredShape.cs +++ b/UnityPackage/Runtime/Components/WireframeCenteredShape.cs @@ -4,8 +4,9 @@ namespace reromanlee.Wireframes { /// /// Base of the shape components centered on a point: boxes, rectangles, circles, ellipses, stars, spheres, - /// ellipsoids and spiked spheres. and place the shape relative to its - /// GameObject, and flat shapes lie in the XZ plane of their rotation, so with no rotation they lie flat on it. + /// ellipsoids, spiked spheres, texts and symbols. and place the shape + /// relative to its GameObject. Flat shapes lie in the XZ plane of their rotation, so with no rotation they lie flat on + /// it, while texts and symbols stand in its XY plane and read from the -Z side. /// public abstract class WireframeCenteredShape : WireframeShape { diff --git a/UnityPackage/Runtime/Components/WireframeShape.cs b/UnityPackage/Runtime/Components/WireframeShape.cs index ccb2be3..ca96732 100644 --- a/UnityPackage/Runtime/Components/WireframeShape.cs +++ b/UnityPackage/Runtime/Components/WireframeShape.cs @@ -136,6 +136,15 @@ internal virtual bool CanCreateShape get => true; } + /// + /// False when writing the fields into , the drawn one, may create objects, such as a text + /// that outgrows its room in its chunk, so callbacks that forbid it leave that to a full refresh. + /// + internal virtual bool CanApplyInPlace(Shape shape) + { + return true; + } + /// /// False when nothing is drawn: for fields that can't make a shape, or for a gizmo outside the Editor. /// @@ -362,7 +371,7 @@ private bool NeedsStructuralRefresh() { return _shape == null || _shape.IsDisposed || _shape.IsSuspended || !IsDrawn || _container.Container.IsDisposed || !_container.Key.Equals(CurrentKey()) - || _builtSignature != BuildSignature; + || _builtSignature != BuildSignature || !CanApplyInPlace(_shape); } private SharedContainerKey CurrentKey() diff --git a/UnityPackage/Runtime/Core/GlyphShape.cs b/UnityPackage/Runtime/Core/GlyphShape.cs index 83759a1..cb1849f 100644 --- a/UnityPackage/Runtime/Core/GlyphShape.cs +++ b/UnityPackage/Runtime/Core/GlyphShape.cs @@ -69,17 +69,21 @@ internal void RebuildIfStale() } /// - /// Replaces the glyphs, without laying them out, for derived shapes that set several fields at once before one - /// . Returns whether they changed. + /// True while the shape draws as they are now: they are its glyphs, and no glyph data + /// changed since its last layout. /// - protected bool ReplaceGlyphs(WireframeGlyphs glyphs) + protected bool IsLaidOutWith(WireframeGlyphs glyphs) + { + return ReferenceEquals(glyphs, _glyphs) && _layoutVersion == GlyphEdits.Version; + } + + /// + /// Replaces the glyphs without laying them out, for derived shapes that set several fields at once before one + /// . + /// + protected void ReplaceGlyphs(WireframeGlyphs glyphs) { - if (ReferenceEquals(glyphs, _glyphs)) - { - return false; - } _glyphs = glyphs; - return true; } /// diff --git a/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs index fc12391..9481a05 100644 --- a/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs +++ b/UnityPackage/Runtime/Shapes/Symbol/GlyphSymbol.cs @@ -72,11 +72,28 @@ public TSymbol GetSymbol() where TSymbol : unmanaged, Enum return SymbolKeys.ToSymbol(_key); } - /// Sets the symbol, the glyphs and the size at once and lays them out once, for components. + /// + /// True when the shape draws the symbol of with as they are now, + /// so applying another size only moves points. + /// + internal bool Shows(int key, WireframeGlyphs glyphs) + { + return key == _key && IsLaidOutWith(glyphs); + } + + /// + /// Sets the symbol, the glyphs and the size at once and lays them out once, or not at all when nothing changed, + /// for components. + /// internal void Apply(int key, WireframeGlyphs glyphs, float size) { EnsureUsable(); - bool glyphsChanged = ReplaceGlyphs(glyphs) | key != _key; + bool glyphsChanged = !Shows(key, glyphs); + if (!glyphsChanged && size.Equals(_size)) + { + return; + } + ReplaceGlyphs(glyphs); _key = key; _size = size; Rebuild(glyphsChanged); diff --git a/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs b/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs new file mode 100644 index 0000000..9065fac --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs @@ -0,0 +1,115 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Draws a symbol on its GameObject with a glyph of a . Its glyph box, + /// across, is centered on and stands in the XY plane of its rotation, read + /// from the -Z side. A symbol the glyphs lack is drawn as '?', with one warning. + /// + [AddComponentMenu("Wireframes/Symbol")] + [ExecuteAlways] + [HelpURL(HelpUrl)] + public sealed class WireframeSymbol : WireframeCenteredShape + { + // The key of the Star symbol, which new components draw. + private const int DefaultSymbolKey = 1365736337; + + [Tooltip("Glyphs to draw with. Empty draws with the package's Default Glyphs.")] + [SerializeField] private WireframeGlyphs _glyphs; + + [Tooltip("Symbol drawn, from the symbols of the glyphs.")] + [SymbolKey] + [SerializeField] private int _symbol = DefaultSymbolKey; + + [Tooltip("Width and height of the glyph box, in the GameObject's local units.")] + [SerializeField] private float _size = ShapeDefaults.Size; + + /// + /// Glyphs to draw with. A new component in the Editor gets , which null + /// draws with too. + /// + public WireframeGlyphs Glyphs + { + get => _glyphs; + set + { + _glyphs = value; + if (TryGetShape(out GlyphSymbol symbol)) + { + symbol.Glyphs = value; + } + } + } + + /// Width and height of the glyph box, in the GameObject's local units. 1 by default. + public float Size + { + get => _size; + set + { + _size = value; + if (TryGetShape(out GlyphSymbol symbol)) + { + symbol.Size = value; + } + } + } + + /// The hash of the drawn symbol's keyword, the value of its member in generated enums. + internal int SymbolKey + { + get => _symbol; + set + { + _symbol = value; + if (TryGetShape(out GlyphSymbol symbol)) + { + symbol.SymbolKey = value; + } + } + } + + /// + /// Draws , a member of a generated symbol enum, such as DefaultSymbols.Heart. A + /// symbol of the same name from any pack of the glyphs counts, and the enum's None member draws nothing. + /// + /// doesn't have int values. + public void SetSymbol(TSymbol symbol) where TSymbol : unmanaged, Enum + { + SymbolKey = SymbolKeys.Of(symbol); + } + + /// + /// Returns the symbol drawn as a member of . A symbol set from another pack's enum + /// comes back as the member of the same name, or as an undefined value when this enum has none. + /// + /// doesn't have int values. + public TSymbol GetSymbol() where TSymbol : unmanaged, Enum + { + return SymbolKeys.ToSymbol(_symbol); + } + + internal override Shape CreateShape(Transform bone) + { + return new GlyphSymbol(bone, Center, Rotation, _symbol, _glyphs, _size) { WarningContext = this }; + } + + internal override void ApplySizesTo(Shape shape) + { + ((GlyphSymbol)shape).Apply(_symbol, _glyphs, _size); + } + + internal override bool CanApplyInPlace(Shape shape) + { + // Another symbol or other glyphs may need more room than the shape has. + return ((GlyphSymbol)shape).Shows(_symbol, _glyphs); + } + + private void Reset() + { + _glyphs = WireframeGlyphs.Default; + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs.meta b/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs.meta new file mode 100644 index 0000000..e29fde4 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Symbol/WireframeSymbol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a0e84bd39a17ca3a39915f252d9b868 diff --git a/UnityPackage/Runtime/Shapes/Text/GlyphText.cs b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs index a51cb03..9b9f2c4 100644 --- a/UnityPackage/Runtime/Shapes/Text/GlyphText.cs +++ b/UnityPackage/Runtime/Shapes/Text/GlyphText.cs @@ -219,17 +219,30 @@ public void SetText(ReadOnlySpan text) } /// - /// Sets the text, the glyphs and every setting at once and lays them out once, for components, which write all - /// their fields together. Settings are taken as they are, already checked. + /// True when the shape draws with as they are now, so applying + /// them with other settings only moves points and never outgrows the shape's room. + /// + internal bool Shows(ReadOnlySpan text, WireframeGlyphs glyphs) + { + return IsLaidOutWith(glyphs) && text.SequenceEqual(new ReadOnlySpan(_characters, 0, _length)); + } + + /// + /// Sets the text, the glyphs and every setting at once and lays them out once, or not at all when nothing changed, + /// for components, which write all their fields together. Settings are taken as they are, already checked. /// internal void Apply(ReadOnlySpan text, WireframeGlyphs glyphs, in TextSettings settings) { EnsureUsable(); - bool glyphsChanged = ReplaceGlyphs(glyphs); + bool glyphsChanged = !Shows(text, glyphs); + if (!glyphsChanged && settings.Equals(_settings)) + { + return; + } + ReplaceGlyphs(glyphs); if (!text.SequenceEqual(new ReadOnlySpan(_characters, 0, _length))) { CopyText(text); - glyphsChanged = true; } _settings = settings; Rebuild(glyphsChanged); diff --git a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs index 0308618..0349bab 100644 --- a/UnityPackage/Runtime/Shapes/Text/TextSettings.cs +++ b/UnityPackage/Runtime/Shapes/Text/TextSettings.cs @@ -1,9 +1,10 @@ +using System; using UnityEngine; namespace reromanlee.Wireframes { /// How a text lays out its characters, shared by and . - internal struct TextSettings + internal struct TextSettings : IEquatable { internal const float DefaultCharacterSpacing = 0.1f; internal const float DefaultLineSpacing = 0f; @@ -46,6 +47,25 @@ internal static TextSettings Default }; } + public bool Equals(TextSettings other) + { + return CharacterWidth == other.CharacterWidth && CharacterSize.Equals(other.CharacterSize) + && CharacterSpacing.Equals(other.CharacterSpacing) && LineSpacing.Equals(other.LineSpacing) + && Bounds.Equals(other.Bounds) && HorizontalAlignment == other.HorizontalAlignment + && VerticalAlignment == other.VerticalAlignment && Overflow == other.Overflow; + } + + public override bool Equals(object other) + { + return other is TextSettings settings && Equals(settings); + } + + public override int GetHashCode() + { + return HashCode.Combine(CharacterSize, CharacterSpacing, LineSpacing, Bounds, (int)CharacterWidth, + (int)HorizontalAlignment, (int)VerticalAlignment, (int)Overflow); + } + internal static bool IsDefined(WireframeCharacterWidth width) { return width is WireframeCharacterWidth.Proportional or WireframeCharacterWidth.Monospace; diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeText.cs b/UnityPackage/Runtime/Shapes/Text/WireframeText.cs new file mode 100644 index 0000000..f1aa942 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeText.cs @@ -0,0 +1,350 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Draws text on its GameObject with the glyphs of a . It stands in the XY plane of its + /// rotation and reads from the -Z side, laid out in centered on + /// . Characters the glyphs lack are drawn as '?', with one warning each. + /// + [AddComponentMenu("Wireframes/Text")] + [ExecuteAlways] + [HelpURL(HelpUrl)] + public sealed class WireframeText : WireframeCenteredShape, ISerializationCallbackReceiver + { + private const string DefaultText = "Text"; + + [Tooltip("Characters drawn. A new line starts a new line of text, and a tab takes 4 spaces.")] + [TextArea(2, 8)] + [SerializeField] private string _text = DefaultText; + + [Tooltip("Glyphs to draw with. Empty draws with the package's Default Glyphs.")] + [SerializeField] private WireframeGlyphs _glyphs; + + [Tooltip("Proportional gives each character the width of its lines; Monospace gives every character a whole glyph " + + "box, so columns line up.")] + [SerializeField] private WireframeCharacterWidth _characterWidth = WireframeCharacterWidth.Proportional; + + [Tooltip("Height of a glyph box, in the GameObject's local units.")] + [SerializeField] private float _characterSize = ShapeDefaults.Size; + + [Tooltip("Extra room after each character, in glyph boxes.")] + [SerializeField] private float _characterSpacing = TextSettings.DefaultCharacterSpacing; + + [Tooltip("Extra room between lines, in glyph boxes.")] + [SerializeField] private float _lineSpacing = TextSettings.DefaultLineSpacing; + + [Tooltip("Width and height of the rectangle the text is aligned in, centered on Center, in the GameObject's local " + + "units. Selecting the component outlines it in the Scene view.")] + [SerializeField] private Vector2 _bounds = TextSettings.DefaultBounds; + + [Tooltip("Where each line sits across the bounds.")] + [SerializeField] private WireframeHorizontalAlignment _horizontalAlignment = WireframeHorizontalAlignment.Center; + + [Tooltip("Where the lines sit between the top and the bottom of the bounds.")] + [SerializeField] private WireframeVerticalAlignment _verticalAlignment = WireframeVerticalAlignment.Middle; + + [Tooltip("What lines wider than the bounds do: run past them, or wrap onto more lines.")] + [SerializeField] private WireframeTextOverflow _overflow = WireframeTextOverflow.Overflow; + + // Text set with SetText, which becomes the saved string only once something reads or saves it. + [NonSerialized] private char[] _spanText; + [NonSerialized] private int _spanLength; + [NonSerialized] private bool _hasSpanText; + + /// + /// Characters drawn, with \n starting a new line. "Text" by default. Reading it after + /// creates the string once. + /// + public string Text + { + get + { + SaveSpanText(); + return _text; + } + set + { + _text = value ?? string.Empty; + _hasSpanText = false; + if (TryGetShape(out GlyphText text)) + { + text.Text = _text; + } + } + } + + /// + /// Glyphs to draw with. A new component in the Editor gets , which null + /// draws with too. + /// + public WireframeGlyphs Glyphs + { + get => _glyphs; + set + { + _glyphs = value; + if (TryGetShape(out GlyphText text)) + { + text.Glyphs = value; + } + } + } + + /// How far each character moves the next one along. Proportional by default. + /// The value isn't a . + public WireframeCharacterWidth CharacterWidth + { + get => _characterWidth; + set + { + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, $"{nameof(CharacterWidth)} isn't a {nameof(WireframeCharacterWidth)} value."); + } + _characterWidth = value; + if (TryGetShape(out GlyphText text)) + { + text.CharacterWidth = value; + } + } + } + + /// Height of a glyph box, in the GameObject's local units. 1 by default. + public float CharacterSize + { + get => _characterSize; + set + { + _characterSize = value; + if (TryGetShape(out GlyphText text)) + { + text.CharacterSize = value; + } + } + } + + /// Extra room after each character, in glyph boxes. 0.1 by default. + public float CharacterSpacing + { + get => _characterSpacing; + set + { + _characterSpacing = value; + if (TryGetShape(out GlyphText text)) + { + text.CharacterSpacing = value; + } + } + } + + /// Extra room between lines, in glyph boxes. 0 by default. + public float LineSpacing + { + get => _lineSpacing; + set + { + _lineSpacing = value; + if (TryGetShape(out GlyphText text)) + { + text.LineSpacing = value; + } + } + } + + /// + /// Width and height of the rectangle the text is aligned in, centered on + /// , in the GameObject's local units. 4 by 1 by default. + /// + public Vector2 Bounds + { + get => _bounds; + set + { + _bounds = value; + if (TryGetShape(out GlyphText text)) + { + text.Bounds = value; + } + } + } + + /// Where each line sits across the bounds. Centered by default. + /// The value isn't a . + public WireframeHorizontalAlignment HorizontalAlignment + { + get => _horizontalAlignment; + set + { + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, + $"{nameof(HorizontalAlignment)} isn't a {nameof(WireframeHorizontalAlignment)} value."); + } + _horizontalAlignment = value; + if (TryGetShape(out GlyphText text)) + { + text.HorizontalAlignment = value; + } + } + } + + /// Where the lines sit between the top and the bottom of the bounds. Centered by default. + /// The value isn't a . + public WireframeVerticalAlignment VerticalAlignment + { + get => _verticalAlignment; + set + { + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, + $"{nameof(VerticalAlignment)} isn't a {nameof(WireframeVerticalAlignment)} value."); + } + _verticalAlignment = value; + if (TryGetShape(out GlyphText text)) + { + text.VerticalAlignment = value; + } + } + } + + /// What lines wider than the bounds do: run past them, the default, or wrap onto more lines. + /// The value isn't a . + public WireframeTextOverflow Overflow + { + get => _overflow; + set + { + if (!TextSettings.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, $"{nameof(Overflow)} isn't a {nameof(WireframeTextOverflow)} value."); + } + _overflow = value; + if (TryGetShape(out GlyphText text)) + { + text.Overflow = value; + } + } + } + + private TextSettings Settings + { + get => new() + { + CharacterWidth = _characterWidth, + CharacterSize = _characterSize, + CharacterSpacing = _characterSpacing, + LineSpacing = _lineSpacing, + Bounds = _bounds, + HorizontalAlignment = _horizontalAlignment, + VerticalAlignment = _verticalAlignment, + Overflow = _overflow + }; + } + + private ReadOnlySpan CurrentText + { + get => _hasSpanText ? new ReadOnlySpan(_spanText, 0, _spanLength) : (_text ?? string.Empty).AsSpan(); + } + + /// + /// Sets the characters drawn without allocating, such as a number written into a stackalloc buffer with + /// TryFormat. They become the saved only once something reads or saves it. + /// + public void SetText(ReadOnlySpan text) + { + if (text.SequenceEqual(CurrentText)) + { + return; + } + if (_spanText == null || _spanText.Length < text.Length) + { + _spanText = new char[Math.Max(text.Length, (_spanText?.Length ?? 8) * 2)]; + } + text.CopyTo(_spanText); + _spanLength = text.Length; + _hasSpanText = true; + if (TryGetShape(out GlyphText shape)) + { + shape.SetText(text); + } + } + + internal override Shape CreateShape(Transform bone) + { + return new GlyphText(bone, Center, Rotation, CurrentText, _glyphs, Settings) { WarningContext = this }; + } + + internal override void ApplySizesTo(Shape shape) + { + ((GlyphText)shape).Apply(CurrentText, _glyphs, Settings); + } + + internal override bool CanApplyInPlace(Shape shape) + { + // Other text or glyphs may need more room than the shape has. + return ((GlyphText)shape).Shows(CurrentText, _glyphs); + } + + internal override void Sanitize() + { + _text ??= string.Empty; + if (!TextSettings.IsDefined(_characterWidth)) + { + _characterWidth = WireframeCharacterWidth.Proportional; + } + if (!TextSettings.IsDefined(_horizontalAlignment)) + { + _horizontalAlignment = WireframeHorizontalAlignment.Center; + } + if (!TextSettings.IsDefined(_verticalAlignment)) + { + _verticalAlignment = WireframeVerticalAlignment.Middle; + } + if (!TextSettings.IsDefined(_overflow)) + { + _overflow = WireframeTextOverflow.Overflow; + } + } + + void ISerializationCallbackReceiver.OnBeforeSerialize() + { + SaveSpanText(); + } + + void ISerializationCallbackReceiver.OnAfterDeserialize() + { + // The saved string is the text from now on, as after an edit in the Inspector or an undo. + _hasSpanText = false; + } + + private void SaveSpanText() + { + if (_hasSpanText) + { + _text = new string(_spanText, 0, _spanLength); + _hasSpanText = false; + } + } + + private void Reset() + { + _glyphs = WireframeGlyphs.Default; + } + +#if UNITY_EDITOR + private void OnDrawGizmosSelected() + { + Gizmos.matrix = transform.localToWorldMatrix * Matrix4x4.TRS(Center, Rotation, Vector3.one); + Gizmos.color = new Color(Color.r, Color.g, Color.b, Color.a * 0.4f); + Gizmos.DrawWireCube(Vector3.zero, new Vector3(_bounds.x, _bounds.y, 0f)); + } +#endif + } +} diff --git a/UnityPackage/Runtime/Shapes/Text/WireframeText.cs.meta b/UnityPackage/Runtime/Shapes/Text/WireframeText.cs.meta new file mode 100644 index 0000000..7e03f1d --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Text/WireframeText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b49846dfed61f7a461a987af663f0b83 diff --git a/UnityPackage/Tests/Editor/GlyphEditModeTests.cs b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs new file mode 100644 index 0000000..bee0ca2 --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using NUnit.Framework; +using reromanlee.Wireframes.Editor; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes.Tests +{ + /// Texts and symbols in Edit Mode, kept in step with the Inspector. + public class GlyphEditModeTests + { + private readonly List _objects = new(); + + [SetUp] + public void OpenEmptyScene() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [TearDown] + public void DestroyEverything() + { + for (int i = _objects.Count - 1; i >= 0; i--) + { + if (_objects[i] != null) + { + Object.DestroyImmediate(_objects[i]); + } + } + _objects.Clear(); + ShapeComponents.TakeRepaintRequest(); + } + + [Test] + public void InspectorTextEdit_IsAppliedOnTheEditorsNextUpdate() + { + WireframeText text = AddText(); + SerializedObject serialized = new(text); + + serialized.FindProperty("_text").stringValue = "AAA"; + serialized.ApplyModifiedProperties(); + + // More text may need more room than the shape has, and finding it may create objects, which OnValidate forbids. + Assert.That(text.Shape.EdgeCount, Is.EqualTo(1)); + Assert.That(text.IsDeferred, Is.True); + + ComponentRefresh.Update(); + + Assert.That(text.Shape.EdgeCount, Is.EqualTo(3)); + Assert.That(text.IsDeferred, Is.False); + } + + [Test] + public void InspectorLayoutEdit_ShowsUpRightAway() + { + WireframeText text = AddText(); + SerializedObject serialized = new(text); + + serialized.FindProperty("_characterSize").floatValue = 2f; + serialized.ApplyModifiedProperties(); + + Assert.That(((GlyphText)text.Shape).CharacterSize, Is.EqualTo(2f)); + Assert.That(text.IsDeferred, Is.False); + } + + /// A text component that draws "A", a single line, with glyphs made for the test. + private WireframeText AddText() + { + WireframeGlyphPack pack = Track(ScriptableObject.CreateInstance()); + GlyphStroke line = new(new[] { new Vector2(0.2f, 0.25f), new Vector2(0.8f, 0.25f) }, false); + pack.SetGlyphs(new[] { new CharacterGlyph('A', new[] { line }) }, null); + WireframeGlyphs glyphs = Track(ScriptableObject.CreateInstance()); + glyphs.SetPacks(pack); + + GameObject owner = Track(new GameObject("Text")); + owner.SetActive(false); + WireframeText text = owner.AddComponent(); + text.Glyphs = glyphs; + text.Text = "A"; + owner.SetActive(true); + return text; + } + + private T Track(T target) where T : Object + { + _objects.Add(target); + return target; + } + } +} diff --git a/UnityPackage/Tests/Editor/GlyphEditModeTests.cs.meta b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs.meta new file mode 100644 index 0000000..ff990c3 --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5cbd129fab3d0042fbaddb954adee61c diff --git a/UnityPackage/Tests/Runtime/GlyphComponentTests.cs b/UnityPackage/Tests/Runtime/GlyphComponentTests.cs new file mode 100644 index 0000000..bd663c9 --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphComponentTests.cs @@ -0,0 +1,159 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools.Constraints; +using Is = UnityEngine.TestTools.Constraints.Is; + +namespace reromanlee.Wireframes.Tests +{ + /// + /// Text and symbol components, drawn with the glyphs of , which they are + /// given before they are enabled. + /// + public class GlyphComponentTests : GlyphTestBase + { + private enum TestSymbols + { + None = 0, + Heart = -1226833789, + Star = 1365736337 + } + + [Test] + public void AddedText_DrawsItsTextOnItsGameObject() + { + WireframeText text = AddGlyphComponent(new Vector3(1f, 2f, 3f)); + + // A runs from -0.3 to 0.3 across the text, 0.25 below its center. + Vector3[] vertices = Bake(text); + AssertApproximately(new Vector3(0.7f, 1.75f, 3f), vertices[0]); + AssertApproximately(new Vector3(1.3f, 1.75f, 3f), vertices[1]); + } + + [Test] + public void TextEdits_ShowUpInTheShape() + { + WireframeText text = AddGlyphComponent(); + + text.CharacterSize = 0.5f; + text.HorizontalAlignment = WireframeHorizontalAlignment.Left; + text.VerticalAlignment = WireframeVerticalAlignment.Top; + text.Bounds = new Vector2(2f, 2f); + text.Center = new Vector3(0f, 0f, 1f); + + Vector3[] vertices = Bake(text); + AssertApproximately(new Vector3(-1f, 1f - 0.5f + Baseline * 0.5f, 1f), vertices[0]); + Assert.That(((GlyphText)text.Shape).CharacterSize, Is.EqualTo(0.5f)); + } + + [Test] + public void SettingTheTextEveryFrame_AllocatesNothing() + { + WireframeText text = AddGlyphComponent(); + WireframeContainer container = text.SharedContainer.Container; + char[] characters = { 'A', 'B', 'B', 'A', 'B' }; + + void ChangeTheTextBackAndForth() + { + text.SetText(characters.AsSpan(0, 5)); + container.Proxy.Flush(); + text.SetText(characters.AsSpan(0, 2)); + container.Proxy.Flush(); + } + + ChangeTheTextBackAndForth(); + ChangeTheTextBackAndForth(); + + Assert.That(ChangeTheTextBackAndForth, Is.Not.AllocatingGCMemory()); + } + + [Test] + public void TextSetWithASpan_BecomesTheSavedText() + { + WireframeText text = AddGlyphComponent(); + + text.SetText("BA".AsSpan()); + ((ISerializationCallbackReceiver)text).OnBeforeSerialize(); + + Assert.That(text.Text, Is.EqualTo("BA")); + Assert.That(((GlyphText)text.Shape).Text, Is.EqualTo("BA")); + } + + [Test] + public void TextEditedWhileDisabled_IsDrawnOnceEnabled() + { + WireframeText text = AddGlyphComponent(); + WireframeGlyphPack pack = text.Glyphs.Packs[0]; + text.enabled = false; + + text.Text = "AB"; + pack.SetGlyphs(new[] { Character('A', Open(0f, 0f, 1f, 0f, 1f, 1f)), Character('B') }, null); + text.enabled = true; + + Assert.That(text.Shape.EdgeCount, Is.EqualTo(2)); + } + + [Test] + public void InvalidTextValues_ThrowAndChangeNothing() + { + WireframeText text = AddGlyphComponent(); + + Assert.Throws(() => text.CharacterWidth = (WireframeCharacterWidth)5); + Assert.Throws(() => text.Overflow = (WireframeTextOverflow)5); + Assert.That(text.CharacterWidth, Is.EqualTo(WireframeCharacterWidth.Proportional)); + Assert.That(text.Overflow, Is.EqualTo(WireframeTextOverflow.Overflow)); + } + + [Test] + public void AddedSymbol_DrawsItsSymbolOnItsGameObject() + { + WireframeSymbol symbol = AddGlyphComponent(new Vector3(0f, 0f, 2f)); + + symbol.SetSymbol(TestSymbols.Heart); + symbol.Size = 2f; + + Vector3[] vertices = Bake(symbol); + Assert.That(vertices, Has.Length.EqualTo(16)); + AssertApproximately(new Vector3(-1f, -1f, 2f), vertices[0]); + AssertApproximately(new Vector3(0f, 1f, 2f), vertices[2]); + Assert.That(symbol.GetSymbol(), Is.EqualTo(TestSymbols.Heart)); + } + + [Test] + public void NewSymbol_DrawsTheStar() + { + WireframeSymbol symbol = AddGlyphComponent(); + + Assert.That(symbol.GetSymbol(), Is.EqualTo(TestSymbols.Star)); + Assert.That(symbol.Shape.EdgeCount, Is.EqualTo(1)); + } + + /// + /// A component on a new GameObject, given the test glyphs before it is enabled, and for a text, the text "A". + /// + private T AddGlyphComponent(Vector3 position = default) where T : WireframeShape + { + GameObject owner = Track(new GameObject(typeof(T).Name)); + owner.SetActive(false); + owner.transform.position = position; + T component = owner.AddComponent(); + WireframeGlyphs glyphs = CreateTestGlyphs(); + if (component is WireframeText text) + { + text.Glyphs = glyphs; + text.Text = "A"; + } + else if (component is WireframeSymbol symbol) + { + symbol.Glyphs = glyphs; + } + owner.SetActive(true); + return component; + } + + private static Vector3[] Bake(WireframeShape component) + { + return BakeShape(component.SharedContainer.Container, component.Shape); + } + } +} diff --git a/UnityPackage/Tests/Runtime/GlyphComponentTests.cs.meta b/UnityPackage/Tests/Runtime/GlyphComponentTests.cs.meta new file mode 100644 index 0000000..1b6c033 --- /dev/null +++ b/UnityPackage/Tests/Runtime/GlyphComponentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fa8dc59ffa9a8a052a7a54bc04d73db From 3a655939ffc95e80d3d6f5e7c9ac71a895dc33b7 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:18:32 +0300 Subject: [PATCH 08/30] feature: live pack edits --- UnityPackage/Editor/Glyphs.meta | 8 ++ UnityPackage/Editor/Glyphs/GlyphRefresh.cs | 33 ++++++++ .../Editor/Glyphs/GlyphRefresh.cs.meta | 2 + UnityPackage/Runtime/Core/GlyphShape.cs | 26 +++++- UnityPackage/Runtime/Core/Shape.cs | 16 ++++ UnityPackage/Runtime/Glyphs/GlyphShapes.cs | 79 +++++++++++++++++++ .../Runtime/Glyphs/GlyphShapes.cs.meta | 2 + .../Tests/Editor/GlyphEditModeTests.cs | 39 ++++++++- 8 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 UnityPackage/Editor/Glyphs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphRefresh.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphRefresh.cs.meta create mode 100644 UnityPackage/Runtime/Glyphs/GlyphShapes.cs create mode 100644 UnityPackage/Runtime/Glyphs/GlyphShapes.cs.meta diff --git a/UnityPackage/Editor/Glyphs.meta b/UnityPackage/Editor/Glyphs.meta new file mode 100644 index 0000000..7bfc6e4 --- /dev/null +++ b/UnityPackage/Editor/Glyphs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 26161c5e4ddc087a0eacfef8c442387f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Editor/Glyphs/GlyphRefresh.cs b/UnityPackage/Editor/Glyphs/GlyphRefresh.cs new file mode 100644 index 0000000..d77c95f --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphRefresh.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Lays texts and symbols out again on the editor's update after a glyph pack or glyph list changed, by an edit, undo + /// or reimport, so every scene shows the change at once, in Edit Mode and Play Mode alike. It does no work while + /// nothing changes. + /// + [InitializeOnLoad] + internal static class GlyphRefresh + { + static GlyphRefresh() + { + EditorApplication.update += Update; + } + + internal static void Update() + { + if (!GlyphEdits.TakePendingChanges()) + { + return; + } + // Play Mode renders every frame anyway; Edit Mode needs the views repainted to show the change. + if (GlyphShapes.RebuildStale() > 0 && !Application.isPlaying) + { + InternalEditorUtility.RepaintAllViews(); + } + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphRefresh.cs.meta b/UnityPackage/Editor/Glyphs/GlyphRefresh.cs.meta new file mode 100644 index 0000000..7388b59 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphRefresh.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fbb62d7c2c0d58c979a0a99f6a5c5cb8 diff --git a/UnityPackage/Runtime/Core/GlyphShape.cs b/UnityPackage/Runtime/Core/GlyphShape.cs index cb1849f..94deb0d 100644 --- a/UnityPackage/Runtime/Core/GlyphShape.cs +++ b/UnityPackage/Runtime/Core/GlyphShape.cs @@ -53,19 +53,27 @@ public WireframeGlyphs Glyphs /// The object that warnings about missing glyphs point to, such as the component that draws the shape. internal Object WarningContext { get; set; } + /// Position in the Editor's list of glyph shapes in containers, or -1 while not in it. + internal int LiveIndex { get; set; } = -1; + /// The glyphs the shape draws with: its own, or the package's default ones when it has none. protected WireframeGlyphs DrawnGlyphs { get => DrawnGlyphsOf(_glyphs); } - /// Lays the glyphs out again if any glyph pack or glyph list changed since, as only happens in the Editor. - internal void RebuildIfStale() + /// + /// Lays the glyphs out again if any glyph pack or glyph list changed since, as only happens in the Editor, and + /// returns whether it did. + /// + internal bool RebuildIfStale() { - if (_layoutVersion != GlyphEdits.Version) + if (_layoutVersion == GlyphEdits.Version) { - Rebuild(true); + return false; } + Rebuild(true); + return true; } /// @@ -112,6 +120,16 @@ protected void Rebuild(bool glyphsChanged) /// Writes the shape's glyphs into , in its own space. protected abstract void Layout(GlyphGeometry geometry, WireframeGlyphs glyphs); + protected sealed override void OnAttached() + { + GlyphShapes.Add(this); + } + + protected sealed override void OnDetached() + { + GlyphShapes.Remove(this); + } + protected sealed override void WriteShape(Span positions) { int count = _geometry.VertexCount; diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index 5a10572..66387f5 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -123,6 +123,7 @@ public void Dispose() ReleaseBones(host.Bones); _edgeSource.Release(); _host = null; + OnDetached(); } public abstract void SetColor(Color color); @@ -149,6 +150,7 @@ internal void Attach(MeshProxy proxy) throw; } MarkDirty(DirtyFlags.All); + OnAttached(); } /// @@ -165,6 +167,7 @@ internal void Suspend() _dirty = DirtyFlags.None; _host = null; _isSuspended = true; + OnDetached(); } /// @@ -186,6 +189,7 @@ internal void Resume(MeshProxy proxy) } _isSuspended = false; MarkDirty(DirtyFlags.All); + OnAttached(); } /// @@ -218,6 +222,7 @@ internal void Resize(int vertexCount, EdgeSource edgeSource) ReleaseBones(host.Bones); _edgeSource.Release(); _host = null; + OnDetached(); throw; } MarkDirty(DirtyFlags.All); @@ -290,6 +295,7 @@ internal void Detach() { _edgeSource.Release(); _host = null; + OnDetached(); } internal void WriteEdges(EdgeList edges, DirtyRanges ranges) @@ -302,6 +308,16 @@ internal void WriteEdges(EdgeList edges, DirtyRanges ranges) } } + /// Called once the shape is in a host: after it is attached or resumed. + protected virtual void OnAttached() + { + } + + /// Called once the shape leaves its host: when it is suspended or disposed, or its host goes away. + protected virtual void OnDetached() + { + } + internal abstract void WritePositions(Span positions); internal abstract void WriteColors(Span colors); diff --git a/UnityPackage/Runtime/Glyphs/GlyphShapes.cs b/UnityPackage/Runtime/Glyphs/GlyphShapes.cs new file mode 100644 index 0000000..d9c887f --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphShapes.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace reromanlee.Wireframes +{ + /// + /// The glyph shapes that are in containers, so the Editor can lay them out again after a glyph pack or a glyph list + /// changes, for components and shapes created from code alike. Only the Editor changes glyph data, so builds keep no + /// list. + /// + internal static class GlyphShapes + { +#if UNITY_EDITOR + private static readonly List Live = new(); +#endif + + [Conditional("UNITY_EDITOR")] + internal static void Add(GlyphShape shape) + { +#if UNITY_EDITOR + shape.LiveIndex = Live.Count; + Live.Add(shape); +#endif + } + + [Conditional("UNITY_EDITOR")] + internal static void Remove(GlyphShape shape) + { +#if UNITY_EDITOR + int index = shape.LiveIndex; + if (index < 0) + { + return; + } + int last = Live.Count - 1; + GlyphShape moved = Live[last]; + Live[index] = moved; + moved.LiveIndex = index; + Live.RemoveAt(last); + shape.LiveIndex = -1; +#endif + } + +#if UNITY_EDITOR + /// + /// Lays out again every shape laid out before the last change to glyph data, and returns how many it did. A shape + /// that fails is logged once and left as it was. + /// + internal static int RebuildStale() + { + int count = 0; + // Backwards, so a shape that a failed rebuild disposes takes the place of one already visited. + for (int i = Live.Count - 1; i >= 0; i--) + { + GlyphShape shape = Live[i]; + try + { + if (shape.RebuildIfStale()) + { + count++; + } + } + catch (Exception exception) + { + if (!shape.HasReportedProblem) + { + shape.HasReportedProblem = true; + WireframesLog.Error( + $"A {shape.GetType().Name} failed to update after its glyphs changed.", exception, + shape.WarningContext); + } + } + } + return count; + } +#endif + } +} diff --git a/UnityPackage/Runtime/Glyphs/GlyphShapes.cs.meta b/UnityPackage/Runtime/Glyphs/GlyphShapes.cs.meta new file mode 100644 index 0000000..cc60249 --- /dev/null +++ b/UnityPackage/Runtime/Glyphs/GlyphShapes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97d9a23ebb21669fb12727a1d6b6a9a4 diff --git a/UnityPackage/Tests/Editor/GlyphEditModeTests.cs b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs index bee0ca2..1c4757c 100644 --- a/UnityPackage/Tests/Editor/GlyphEditModeTests.cs +++ b/UnityPackage/Tests/Editor/GlyphEditModeTests.cs @@ -8,10 +8,11 @@ namespace reromanlee.Wireframes.Tests { - /// Texts and symbols in Edit Mode, kept in step with the Inspector. + /// Texts and symbols in Edit Mode, kept in step with the Inspector and with edits to their glyph packs. public class GlyphEditModeTests { private readonly List _objects = new(); + private readonly List _containers = new(); [SetUp] public void OpenEmptyScene() @@ -22,6 +23,11 @@ public void OpenEmptyScene() [TearDown] public void DestroyEverything() { + foreach (WireframeContainer container in _containers) + { + container.Dispose(); + } + _containers.Clear(); for (int i = _objects.Count - 1; i >= 0; i--) { if (_objects[i] != null) @@ -65,6 +71,37 @@ public void InspectorLayoutEdit_ShowsUpRightAway() Assert.That(text.IsDeferred, Is.False); } + [Test] + public void EditedPack_RedrawsItsTextsOnTheEditorsNextUpdateAndUndoToo() + { + WireframeText component = AddText(); + WireframeContainer container = new(); + _containers.Add(container); + IText code = container.CreateText("A"); + code.Glyphs = component.Glyphs; + WireframeGlyphPack pack = component.Glyphs.Packs[0]; + Undo.IncrementCurrentGroup(); + + // As the Glyph Editor does: a third point on A's stroke. + SerializedObject serialized = new(pack); + SerializedProperty points = serialized.FindProperty(WireframeGlyphPack.CharactersField) + .GetArrayElementAtIndex(0).FindPropertyRelative(CharacterGlyph.StrokesField) + .GetArrayElementAtIndex(0).FindPropertyRelative(GlyphStroke.PointsField); + points.arraySize = 3; + points.GetArrayElementAtIndex(2).vector2Value = new Vector2(0.8f, 0.75f); + serialized.ApplyModifiedProperties(); + GlyphRefresh.Update(); + + Assert.That(component.Shape.EdgeCount, Is.EqualTo(2)); + Assert.That(((Shape)code).EdgeCount, Is.EqualTo(2)); + + Undo.PerformUndo(); + GlyphRefresh.Update(); + + Assert.That(component.Shape.EdgeCount, Is.EqualTo(1)); + Assert.That(((Shape)code).EdgeCount, Is.EqualTo(1)); + } + /// A text component that draws "A", a single line, with glyphs made for the test. private WireframeText AddText() { From 38ac85cb61618ab8f14c1ebdbf27c02680a7e218 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:26:27 +0300 Subject: [PATCH 09/30] feature: enum generator --- .../Editor/Glyphs/GlyphEnumGenerator.cs | 216 ++++++++++++++++++ .../Editor/Glyphs/GlyphEnumGenerator.cs.meta | 2 + .../Tests/Editor/GlyphEnumGeneratorTests.cs | 130 +++++++++++ .../Editor/GlyphEnumGeneratorTests.cs.meta | 2 + 4 files changed, 350 insertions(+) create mode 100644 UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs.meta create mode 100644 UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs create mode 100644 UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs.meta diff --git a/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs b/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs new file mode 100644 index 0000000..067c62c --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using UnityEditor; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Writes a glyph pack's symbol enum: a C# file next to the pack with one member per symbol, valued by its keyword's + /// hash, plus None. The file names the pack's GUID, so it's found again wherever it's moved, and a copy of the pack + /// never overwrites the original's enum. + /// + internal static class GlyphEnumGenerator + { + private const string Indent = " "; + private static readonly Regex PackGuidLine = new(@"^// Glyph pack GUID: ([0-9a-f]{32})", RegexOptions.Multiline); + + /// The enum's name: the pack's setting, or the pack's name made into a C# identifier. + internal static string EnumNameOf(WireframeGlyphPack pack) + { + string name = pack.EnumSettings.Name; + return name.Length > 0 ? name : IdentifierOf(pack.name); + } + + /// The enum's namespace: the pack's setting, or the project's root namespace; empty for none. + internal static string NamespaceOf(WireframeGlyphPack pack) + { + string namespaceName = pack.EnumSettings.Namespace; + return namespaceName.Length > 0 ? namespaceName : EditorSettings.projectGenerationRootNamespace ?? string.Empty; + } + + /// + /// Returns why the pack's enum can't be generated, or null when it can: a keyword that isn't valid, a keyword + /// used twice, two keywords with the same hash, or a name or namespace that isn't a C# identifier. + /// + internal static string FindProblem(WireframeGlyphPack pack) + { + string enumName = EnumNameOf(pack); + if (!IsIdentifier(enumName)) + { + return $"'{enumName}' can't name an enum. Set a C# identifier as the enum's name in Pack Settings."; + } + string namespaceName = NamespaceOf(pack); + if (namespaceName.Length > 0 && !Array.TrueForAll(namespaceName.Split('.'), IsIdentifier)) + { + return $"'{namespaceName}' isn't a namespace. Set C# identifiers joined by dots in Pack Settings."; + } + Dictionary keywords = new(); + foreach (SymbolGlyph symbol in pack.Symbols) + { + string keyword = symbol.Keyword; + string problem = SymbolKeys.FindProblem(keyword); + if (problem != null) + { + return keyword.Length > 0 ? $"{keyword}: {problem}" : problem; + } + int key = SymbolKeys.Of(keyword); + if (keywords.TryGetValue(key, out string other)) + { + return other == keyword + ? $"The pack has two symbols named {keyword}." + : $"{other} and {keyword} have the same hash, so one of them needs another name."; + } + keywords.Add(key, keyword); + } + return null; + } + + /// The source of the pack's enum, with \n line endings. + internal static string Generate(WireframeGlyphPack pack) + { + string enumName = EnumNameOf(pack); + string namespaceName = NamespaceOf(pack); + string guid = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(pack)); + string indent = namespaceName.Length > 0 ? Indent : string.Empty; + + StringBuilder source = new(); + source.Append("// \n"); + source.Append($"// Generated by Wireframes from the glyph pack {pack.name}. Edit its symbols in the Glyph Editor and\n"); + source.Append("// generate the enum again instead of editing this file.\n"); + source.Append($"// Glyph pack GUID: {guid}\n"); + source.Append("// \n\n"); + if (namespaceName.Length > 0) + { + source.Append($"namespace {namespaceName}\n{{\n"); + } + string summaryName = pack.name.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + source.Append($"{indent}/// The symbols of the glyph pack {summaryName}, for symbol shapes and components.\n"); + source.Append($"{indent}public enum {enumName}\n{indent}{{\n"); + source.Append($"{indent}{Indent}{SymbolKeys.NoneName} = {SymbolKeys.None}"); + foreach (SymbolGlyph symbol in pack.Symbols) + { + source.Append($",\n{indent}{Indent}{symbol.Keyword} = {SymbolKeys.Of(symbol.Keyword)}"); + } + source.Append($"\n{indent}}}\n"); + if (namespaceName.Length > 0) + { + source.Append("}\n"); + } + return source.ToString(); + } + + /// + /// Where the pack's enum is: the script it was last written to, found by GUID, while that script still names the + /// pack, or otherwise a script named after the enum next to the pack. + /// + internal static string ScriptPathOf(WireframeGlyphPack pack) + { + string packPath = AssetDatabase.GetAssetPath(pack); + string scriptGuid = pack.EnumSettings.ScriptGuid; + if (scriptGuid.Length > 0) + { + string scriptPath = AssetDatabase.GUIDToAssetPath(scriptGuid); + if (!string.IsNullOrEmpty(scriptPath) && IsScriptOf(scriptPath, packPath)) + { + return scriptPath; + } + } + return $"{Path.GetDirectoryName(packPath)?.Replace('\\', '/')}/{EnumNameOf(pack)}.cs"; + } + + /// True when the pack's enum script is missing or differs from what generating it now would write. + internal static bool IsOutOfDate(WireframeGlyphPack pack) + { + string path = FileUtil.GetPhysicalPath(ScriptPathOf(pack)); + if (!File.Exists(path)) + { + return true; + } + string written = File.ReadAllText(path).Replace("\r\n", "\n"); + return written != Generate(pack); + } + + /// + /// Writes the pack's enum and imports it, which compiles scripts again, and remembers the script on the pack. + /// + /// The enum can't be generated, as tells. + internal static void Write(WireframeGlyphPack pack) + { + if (!AssetDatabase.Contains(pack)) + { + throw new InvalidOperationException("Save the glyph pack as an asset before generating its enum."); + } + string problem = FindProblem(pack); + if (problem != null) + { + throw new InvalidOperationException(problem); + } + string scriptPath = ScriptPathOf(pack); + string physicalPath = FileUtil.GetPhysicalPath(scriptPath); + if (File.Exists(physicalPath) && !IsScriptOf(scriptPath, AssetDatabase.GetAssetPath(pack))) + { + throw new InvalidOperationException( + $"{scriptPath} already exists and wasn't generated for this pack. Set another enum name in Pack Settings."); + } + File.WriteAllText(physicalPath, Generate(pack), new UTF8Encoding(false)); + AssetDatabase.ImportAsset(scriptPath); + + SerializedObject serialized = new(pack); + SerializedProperty settings = serialized.FindProperty(WireframeGlyphPack.EnumSettingsField); + settings.FindPropertyRelative(GlyphEnumSettings.ScriptGuidField).stringValue = + AssetDatabase.AssetPathToGUID(scriptPath); + serialized.ApplyModifiedPropertiesWithoutUndo(); + AssetDatabase.SaveAssetIfDirty(pack); + } + + /// + /// A C# identifier made from : its letters and digits, each word starting with a capital, + /// and an underscore first when it would start with a digit. + /// + internal static string IdentifierOf(string name) + { + StringBuilder identifier = new(); + bool startsWord = true; + foreach (char character in name) + { + bool isLetter = character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + bool isDigit = character is >= '0' and <= '9'; + if (!isLetter && !isDigit) + { + startsWord = true; + continue; + } + identifier.Append(startsWord ? char.ToUpperInvariant(character) : character); + startsWord = false; + } + if (identifier.Length == 0) + { + return "Symbols"; + } + if (identifier[0] is >= '0' and <= '9') + { + identifier.Insert(0, '_'); + } + return identifier.ToString(); + } + + private static bool IsIdentifier(string name) + { + return SymbolKeys.FindProblem(name) == null || name == SymbolKeys.NoneName; + } + + private static bool IsScriptOf(string scriptPath, string packPath) + { + string physicalPath = FileUtil.GetPhysicalPath(scriptPath); + if (!File.Exists(physicalPath)) + { + return false; + } + Match match = PackGuidLine.Match(File.ReadAllText(physicalPath)); + return match.Success && match.Groups[1].Value == AssetDatabase.AssetPathToGUID(packPath); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs.meta b/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs.meta new file mode 100644 index 0000000..6c15f84 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEnumGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69d466001fa7b2383091a9c934a09086 diff --git a/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs b/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs new file mode 100644 index 0000000..9a5943b --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs @@ -0,0 +1,130 @@ +using System.IO; +using NUnit.Framework; +using reromanlee.Wireframes.Editor; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.Wireframes.Tests +{ + /// The symbol enums generated for glyph packs. + public class GlyphEnumGeneratorTests + { + private const string Folder = "Assets/WireframesGlyphEnumTest"; + + private WireframeGlyphPack _pack; + + [SetUp] + public void CreatePack() + { + _pack = ScriptableObject.CreateInstance(); + _pack.name = "Test Pack"; + GlyphStroke line = new(new[] { Vector2.zero, Vector2.one }, false); + _pack.SetGlyphs(null, new[] { new SymbolGlyph("ArrowUp", new[] { line }), new SymbolGlyph("Heart", null) }); + } + + [TearDown] + public void DestroyPack() + { + if (AssetDatabase.Contains(_pack)) + { + File.Delete(FileUtil.GetPhysicalPath($"{Folder}/TestPack.cs")); + AssetDatabase.DeleteAsset(Folder); + } + else + { + Object.DestroyImmediate(_pack); + } + } + + [Test] + public void Enum_HasNoneAndAMemberPerSymbolValuedByItsKey() + { + _pack.EnumSettings = new GlyphEnumSettings("TestSymbols", "Game.Icons", null); + + string source = GlyphEnumGenerator.Generate(_pack); + + Assert.That(source, Is.EqualTo( + "// \n" + + "// Generated by Wireframes from the glyph pack Test Pack. Edit its symbols in the Glyph Editor and\n" + + "// generate the enum again instead of editing this file.\n" + + "// Glyph pack GUID: \n" + + "// \n" + + "\n" + + "namespace Game.Icons\n" + + "{\n" + + " /// The symbols of the glyph pack Test Pack, for symbol shapes and components.\n" + + " public enum TestSymbols\n" + + " {\n" + + " None = 0,\n" + + " ArrowUp = 154847355,\n" + + " Heart = -1226833789\n" + + " }\n" + + "}\n")); + } + + [Test] + public void EnumWithoutANamespace_IsLeftInTheGlobalOne() + { + _pack.EnumSettings = new GlyphEnumSettings("TestSymbols", null, null); + string rootNamespace = EditorSettings.projectGenerationRootNamespace; + EditorSettings.projectGenerationRootNamespace = string.Empty; + try + { + string source = GlyphEnumGenerator.Generate(_pack); + + Assert.That(source, Does.Not.Contain("namespace")); + Assert.That(source, Does.Contain("\npublic enum TestSymbols\n{\n None = 0,\n")); + } + finally + { + EditorSettings.projectGenerationRootNamespace = rootNamespace; + } + } + + [TestCase("Default Symbols", "DefaultSymbols")] + [TestCase("my-symbols 2", "MySymbols2")] + [TestCase("2D icons", "_2DIcons")] + [TestCase("!!!", "Symbols")] + public void PackNames_BecomeEnumNames(string packName, string enumName) + { + Assert.That(GlyphEnumGenerator.IdentifierOf(packName), Is.EqualTo(enumName)); + } + + [Test] + public void Problems_AreFoundBeforeAnythingIsWritten() + { + Assert.That(GlyphEnumGenerator.FindProblem(_pack), Is.Null); + + _pack.SetGlyphs(null, new[] { new SymbolGlyph("Heart", null), new SymbolGlyph("Heart", null) }); + Assert.That(GlyphEnumGenerator.FindProblem(_pack), Does.Contain("two symbols named Heart")); + + _pack.SetGlyphs(null, new[] { new SymbolGlyph("class", null) }); + Assert.That(GlyphEnumGenerator.FindProblem(_pack), Does.Contain("C# keyword")); + + _pack.SetGlyphs(null, null); + _pack.EnumSettings = new GlyphEnumSettings("Two Words", null, null); + Assert.That(GlyphEnumGenerator.FindProblem(_pack), Does.Contain("can't name an enum")); + + _pack.EnumSettings = new GlyphEnumSettings("Icons", "Game..Icons", null); + Assert.That(GlyphEnumGenerator.FindProblem(_pack), Does.Contain("isn't a namespace")); + } + + [Test] + public void EnumScript_IsNextToThePackAndOutOfDateUntilWrittenAsGenerated() + { + AssetDatabase.CreateFolder("Assets", Path.GetFileName(Folder)); + AssetDatabase.CreateAsset(_pack, $"{Folder}/Test Pack.asset"); + string scriptPath = GlyphEnumGenerator.ScriptPathOf(_pack); + Assert.That(scriptPath, Is.EqualTo($"{Folder}/TestPack.cs")); + Assert.That(GlyphEnumGenerator.IsOutOfDate(_pack), Is.True); + + // Written without importing it, so no scripts compile during the test. + File.WriteAllText(FileUtil.GetPhysicalPath(scriptPath), GlyphEnumGenerator.Generate(_pack).Replace("\n", "\r\n")); + Assert.That(GlyphEnumGenerator.Generate(_pack), Does.Contain(AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(_pack)))); + Assert.That(GlyphEnumGenerator.IsOutOfDate(_pack), Is.False); + + _pack.SetGlyphs(null, new[] { new SymbolGlyph("Star", null) }); + Assert.That(GlyphEnumGenerator.IsOutOfDate(_pack), Is.True); + } + } +} diff --git a/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs.meta b/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs.meta new file mode 100644 index 0000000..3294d12 --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEnumGeneratorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4bf1c0451d70b202b14a3b9bbb2da44e From 31d8dc4ccf1ae235f14f3a8dac1ac97741723b67 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:29:21 +0300 Subject: [PATCH 10/30] feature: glyph editing --- UnityPackage/Editor/Glyphs/GlyphClipboard.cs | 46 ++ .../Editor/Glyphs/GlyphClipboard.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphGrid.cs | 20 + UnityPackage/Editor/Glyphs/GlyphGrid.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphNames.cs | 68 +++ UnityPackage/Editor/Glyphs/GlyphNames.cs.meta | 2 + .../Editor/Glyphs/GlyphPackEditing.cs | 402 ++++++++++++++++++ .../Editor/Glyphs/GlyphPackEditing.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphReference.cs | 55 +++ .../Editor/Glyphs/GlyphReference.cs.meta | 2 + .../Tests/Editor/GlyphPackEditingTests.cs | 207 +++++++++ .../Editor/GlyphPackEditingTests.cs.meta | 2 + 12 files changed, 810 insertions(+) create mode 100644 UnityPackage/Editor/Glyphs/GlyphClipboard.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphClipboard.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphGrid.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphGrid.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphNames.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphNames.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphPackEditing.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphPackEditing.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphReference.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphReference.cs.meta create mode 100644 UnityPackage/Tests/Editor/GlyphPackEditingTests.cs create mode 100644 UnityPackage/Tests/Editor/GlyphPackEditingTests.cs.meta diff --git a/UnityPackage/Editor/Glyphs/GlyphClipboard.cs b/UnityPackage/Editor/Glyphs/GlyphClipboard.cs new file mode 100644 index 0000000..e01b071 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphClipboard.cs @@ -0,0 +1,46 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Copies glyph strokes as text on the system clipboard, so they can be pasted into another glyph, another pack or + /// another Unity session. + /// + internal static class GlyphClipboard + { + private const string Prefix = "Wireframes glyph strokes:"; + + internal static void Copy(GlyphStroke[] strokes) + { + EditorGUIUtility.systemCopyBuffer = Prefix + JsonUtility.ToJson(new Strokes { Items = strokes }); + } + + /// Reads strokes copied with ; false when the clipboard holds anything else. + internal static bool TryPaste(out GlyphStroke[] strokes) + { + strokes = null; + string text = EditorGUIUtility.systemCopyBuffer; + if (string.IsNullOrEmpty(text) || !text.StartsWith(Prefix, StringComparison.Ordinal)) + { + return false; + } + try + { + strokes = JsonUtility.FromJson(text.Substring(Prefix.Length))?.Items; + } + catch (ArgumentException) + { + return false; + } + return strokes != null && strokes.Length > 0; + } + + [Serializable] + private sealed class Strokes + { + public GlyphStroke[] Items; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphClipboard.cs.meta b/UnityPackage/Editor/Glyphs/GlyphClipboard.cs.meta new file mode 100644 index 0000000..a94b951 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphClipboard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82fe4cd2aed9d1cf379534b82327ddc0 diff --git a/UnityPackage/Editor/Glyphs/GlyphGrid.cs b/UnityPackage/Editor/Glyphs/GlyphGrid.cs new file mode 100644 index 0000000..f81aeae --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphGrid.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace reromanlee.Wireframes.Editor +{ + /// Keeps glyph points in the glyph box, from 0 to 1 on both axes, and on its grid when snapping. + internal static class GlyphGrid + { + internal static Vector2 Clamp(Vector2 point) + { + return new Vector2(Mathf.Clamp01(point.x), Mathf.Clamp01(point.y)); + } + + /// The grid point nearest to , for a grid of across the box. + internal static Vector2 Snap(Vector2 point, int divisions) + { + float step = 1f / Mathf.Max(1, divisions); + return Clamp(new Vector2(Mathf.Round(point.x / step) * step, Mathf.Round(point.y / step) * step)); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphGrid.cs.meta b/UnityPackage/Editor/Glyphs/GlyphGrid.cs.meta new file mode 100644 index 0000000..0981667 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphGrid.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf7bfbf87e2daf4b47b841a55d1f5227 diff --git a/UnityPackage/Editor/Glyphs/GlyphNames.cs b/UnityPackage/Editor/Glyphs/GlyphNames.cs new file mode 100644 index 0000000..34c1686 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphNames.cs @@ -0,0 +1,68 @@ +using System.Globalization; + +namespace reromanlee.Wireframes.Editor +{ + /// How the Glyph Editor names characters, and reads the character someone types to add one. + internal static class GlyphNames + { + /// The character itself, or a name for one that shows nothing, such as Space. + internal static string CharacterOf(int character) + { + if (character == ' ') + { + return "Space"; + } + if (character == 0xA0) + { + return "No-break space"; + } + if (!IsValid(character) || character <= 0xFFFF && char.IsControl((char)character)) + { + return CodeOf(character); + } + return char.ConvertFromUtf32(character); + } + + /// The Unicode notation of , such as U+0041 for A. + internal static string CodeOf(int character) + { + return $"U+{character:X4}"; + } + + /// + /// Reads a character typed as itself, such as A, or as its code, such as U+0041. Returns why it can't be added to + /// a pack, or null when it can. + /// + internal static string Parse(string text, out int character) + { + character = 0; + text ??= string.Empty; + // A space is a character too, so only longer input is trimmed. + string trimmed = text.Length > 1 ? text.Trim() : text; + if (trimmed.Length > 2 && (trimmed[0] == 'U' || trimmed[0] == 'u') && trimmed[1] == '+') + { + if (!int.TryParse(trimmed.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, + out character) || !IsValid(character)) + { + return $"{trimmed} isn't a Unicode character."; + } + } + else if (trimmed.Length == 1 || trimmed.Length == 2 && char.IsSurrogatePair(trimmed[0], trimmed[1])) + { + character = char.ConvertToUtf32(trimmed, 0); + } + else + { + return "Type one character, such as A, or its code, such as U+0041."; + } + return character < 0x20 || character is >= 0x7F and < 0xA0 + ? "Control characters are never drawn, so they can't have a glyph." + : null; + } + + private static bool IsValid(int character) + { + return character is >= 0 and < 0xD800 or > 0xDFFF and <= 0x10FFFF; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphNames.cs.meta b/UnityPackage/Editor/Glyphs/GlyphNames.cs.meta new file mode 100644 index 0000000..40fbdd3 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphNames.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc3afa9c0b5534d4596891b83f8ae5c2 diff --git a/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs b/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs new file mode 100644 index 0000000..a6247bf --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs @@ -0,0 +1,402 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Every edit the Glyph Editor makes to a pack, each one undo step, made through a , so + /// undo, the Inspector and the texts using the pack all follow. Characters stay sorted by code, and symbols keep the + /// order they are given, which is the order of their enum's members. + /// + internal sealed class GlyphPackEditing + { + private const int FirstPrintableAscii = 0x20; + private const int LastPrintableAscii = 0x7E; + + private readonly SerializedObject _serialized; + + internal GlyphPackEditing(WireframeGlyphPack pack) + { + Pack = pack; + _serialized = new SerializedObject(pack); + } + + internal WireframeGlyphPack Pack { get; } + + internal SerializedObject Serialized + { + get => _serialized; + } + + /// The glyph's entry in the pack's list of characters or symbols. + internal SerializedProperty GlyphProperty(GlyphReference glyph) + { + return ListOf(glyph.IsSymbol).GetArrayElementAtIndex(glyph.Index); + } + + internal SerializedProperty StrokesProperty(GlyphReference glyph) + { + string field = glyph.IsSymbol ? SymbolGlyph.StrokesField : CharacterGlyph.StrokesField; + return GlyphProperty(glyph).FindPropertyRelative(field); + } + + internal SerializedProperty PointsProperty(GlyphReference glyph, int stroke) + { + return StrokesProperty(glyph).GetArrayElementAtIndex(stroke).FindPropertyRelative(GlyphStroke.PointsField); + } + + /// The character glyph of , or . + internal GlyphReference FindCharacter(int character) + { + CharacterGlyph[] characters = Pack.Characters; + for (int i = 0; i < characters.Length; i++) + { + if (characters[i].Character == character) + { + return new GlyphReference(false, i); + } + } + return GlyphReference.None; + } + + /// The symbol named , or . + internal GlyphReference FindSymbol(string keyword) + { + SymbolGlyph[] symbols = Pack.Symbols; + for (int i = 0; i < symbols.Length; i++) + { + if (symbols[i].Keyword == keyword) + { + return new GlyphReference(true, i); + } + } + return GlyphReference.None; + } + + /// Adds an empty glyph for where its code sorts it. + internal GlyphReference AddCharacter(int character) + { + int index = SortedIndexOf(character); + Edit("Add Glyph", () => + { + SerializedProperty characters = ListOf(false); + characters.InsertArrayElementAtIndex(index); + SerializedProperty glyph = characters.GetArrayElementAtIndex(index); + glyph.FindPropertyRelative(CharacterGlyph.CharacterField).intValue = character; + glyph.FindPropertyRelative(CharacterGlyph.StrokesField).arraySize = 0; + }); + return new GlyphReference(false, index); + } + + /// Adds an empty symbol named after the others. + internal GlyphReference AddSymbol(string keyword) + { + int index = Pack.Symbols.Length; + Edit("Add Symbol", () => + { + SerializedProperty symbols = ListOf(true); + symbols.arraySize = index + 1; + SerializedProperty glyph = symbols.GetArrayElementAtIndex(index); + glyph.FindPropertyRelative(SymbolGlyph.KeywordField).stringValue = keyword; + glyph.FindPropertyRelative(SymbolGlyph.StrokesField).arraySize = 0; + }); + return new GlyphReference(true, index); + } + + /// Adds an empty glyph for every printable ASCII character the pack lacks, and returns how many. + internal int AddMissingAscii() + { + int added = 0; + Edit("Add Missing ASCII", () => + { + SerializedProperty characters = ListOf(false); + for (int character = FirstPrintableAscii; character <= LastPrintableAscii; character++) + { + if (!FindCharacterIn(characters, character, out int index)) + { + characters.InsertArrayElementAtIndex(index); + SerializedProperty glyph = characters.GetArrayElementAtIndex(index); + glyph.FindPropertyRelative(CharacterGlyph.CharacterField).intValue = character; + glyph.FindPropertyRelative(CharacterGlyph.StrokesField).arraySize = 0; + added++; + } + } + }); + return added; + } + + internal void Delete(GlyphReference glyph) + { + Edit(glyph.IsSymbol ? "Delete Symbol" : "Delete Glyph", () => ListOf(glyph.IsSymbol).DeleteArrayElementAtIndex(glyph.Index)); + } + + /// + /// Copies a character's glyph to , which the pack lacks, and returns the copy. + /// + internal GlyphReference DuplicateCharacter(GlyphReference glyph, int character) + { + GlyphReference copy = GlyphReference.None; + Edit("Duplicate Glyph", () => + { + SerializedProperty characters = ListOf(false); + characters.InsertArrayElementAtIndex(glyph.Index); + characters.GetArrayElementAtIndex(glyph.Index + 1) + .FindPropertyRelative(CharacterGlyph.CharacterField).intValue = character; + copy = new GlyphReference(false, Resort(characters, glyph.Index + 1)); + }); + return copy; + } + + /// Copies a symbol right after itself, named , and returns the copy. + internal GlyphReference DuplicateSymbol(GlyphReference glyph, string keyword) + { + Edit("Duplicate Symbol", () => + { + SerializedProperty symbols = ListOf(true); + symbols.InsertArrayElementAtIndex(glyph.Index); + symbols.GetArrayElementAtIndex(glyph.Index + 1) + .FindPropertyRelative(SymbolGlyph.KeywordField).stringValue = keyword; + }); + return new GlyphReference(true, glyph.Index + 1); + } + + /// Gives a character's glyph to another character, which the pack lacks, and returns where it went. + internal GlyphReference ChangeCharacter(GlyphReference glyph, int character) + { + GlyphReference moved = glyph; + Edit("Change Character", () => + { + SerializedProperty characters = ListOf(false); + characters.GetArrayElementAtIndex(glyph.Index) + .FindPropertyRelative(CharacterGlyph.CharacterField).intValue = character; + moved = new GlyphReference(false, Resort(characters, glyph.Index)); + }); + return moved; + } + + internal void Rename(GlyphReference symbol, string keyword) + { + Edit("Rename Symbol", () => GlyphProperty(symbol).FindPropertyRelative(SymbolGlyph.KeywordField).stringValue = keyword); + } + + /// Moves a symbol to in the pack's order, and with it its enum member. + internal void MoveSymbol(int from, int index) + { + Edit("Move Symbol", () => ListOf(true).MoveArrayElement(from, index)); + } + + /// Adds a stroke at the end of the glyph, with one point at . + internal int AddStroke(GlyphReference glyph, Vector2 firstPoint) + { + int stroke = glyph.StrokesIn(Pack).Length; + Edit("Add Stroke", () => + { + SerializedProperty strokes = StrokesProperty(glyph); + strokes.arraySize = stroke + 1; + SerializedProperty added = strokes.GetArrayElementAtIndex(stroke); + added.FindPropertyRelative(GlyphStroke.IsClosedField).boolValue = false; + SerializedProperty points = added.FindPropertyRelative(GlyphStroke.PointsField); + points.arraySize = 1; + points.GetArrayElementAtIndex(0).vector2Value = firstPoint; + }); + return stroke; + } + + internal void DeleteStroke(GlyphReference glyph, int stroke) + { + Edit("Delete Stroke", () => StrokesProperty(glyph).DeleteArrayElementAtIndex(stroke)); + } + + internal void MoveStroke(GlyphReference glyph, int from, int index) + { + Edit("Move Stroke", () => StrokesProperty(glyph).MoveArrayElement(from, index)); + } + + internal void SetClosed(GlyphReference glyph, int stroke, bool isClosed) + { + Edit(isClosed ? "Close Stroke" : "Open Stroke", () => StrokesProperty(glyph).GetArrayElementAtIndex(stroke) + .FindPropertyRelative(GlyphStroke.IsClosedField).boolValue = isClosed); + } + + /// Inserts a point into a stroke at , the index it takes, which may be the end. + internal void InsertPoint(GlyphReference glyph, int stroke, int point, Vector2 position) + { + Edit("Add Point", () => + { + SerializedProperty points = PointsProperty(glyph, stroke); + if (point >= points.arraySize) + { + points.arraySize = point + 1; + } + else + { + points.InsertArrayElementAtIndex(point); + } + points.GetArrayElementAtIndex(point).vector2Value = position; + }); + } + + /// + /// Moves a point to . With , it joins the undo step + /// of the previous move, as while dragging. + /// + internal void MovePoint(GlyphReference glyph, int stroke, int point, Vector2 position, bool continuesStep = false) + { + Edit("Move Point", () => PointsProperty(glyph, stroke).GetArrayElementAtIndex(point).vector2Value = position, + !continuesStep); + } + + internal void MovePointInOrder(GlyphReference glyph, int stroke, int from, int index) + { + Edit("Reorder Point", () => PointsProperty(glyph, stroke).MoveArrayElement(from, index)); + } + + /// Deletes a point, and its stroke with it when it was the last one. + internal void DeletePoint(GlyphReference glyph, int stroke, int point) + { + Edit("Delete Point", () => + { + SerializedProperty points = PointsProperty(glyph, stroke); + if (points.arraySize <= 1) + { + StrokesProperty(glyph).DeleteArrayElementAtIndex(stroke); + } + else + { + points.DeleteArrayElementAtIndex(point); + } + }); + } + + /// + /// Moves every point of the glyph so the box of its points is centered horizontally or vertically in the glyph + /// box. A glyph without points stays as it is. + /// + internal void Center(GlyphReference glyph, bool isHorizontal) + { + if (!TryGetPointBounds(glyph.StrokesIn(Pack), out Vector2 minimum, out Vector2 maximum)) + { + return; + } + float offset = isHorizontal ? 0.5f - (minimum.x + maximum.x) * 0.5f : 0.5f - (minimum.y + maximum.y) * 0.5f; + Vector2 shift = isHorizontal ? new Vector2(offset, 0f) : new Vector2(0f, offset); + Edit(isHorizontal ? "Center Glyph Horizontally" : "Center Glyph Vertically", () => + { + SerializedProperty strokes = StrokesProperty(glyph); + for (int stroke = 0; stroke < strokes.arraySize; stroke++) + { + SerializedProperty points = strokes.GetArrayElementAtIndex(stroke).FindPropertyRelative(GlyphStroke.PointsField); + for (int point = 0; point < points.arraySize; point++) + { + SerializedProperty position = points.GetArrayElementAtIndex(point); + position.vector2Value = GlyphGrid.Clamp(position.vector2Value + shift); + } + } + }); + } + + /// Adds copies of after the glyph's own, as when pasting. + internal void AddStrokes(GlyphReference glyph, GlyphStroke[] strokes) + { + Edit("Paste Strokes", () => + { + SerializedProperty list = StrokesProperty(glyph); + foreach (GlyphStroke stroke in strokes) + { + list.arraySize++; + SerializedProperty added = list.GetArrayElementAtIndex(list.arraySize - 1); + added.FindPropertyRelative(GlyphStroke.IsClosedField).boolValue = stroke.IsClosed; + SerializedProperty points = added.FindPropertyRelative(GlyphStroke.PointsField); + points.arraySize = stroke.Points.Length; + for (int i = 0; i < stroke.Points.Length; i++) + { + points.GetArrayElementAtIndex(i).vector2Value = GlyphGrid.Clamp(stroke.Points[i]); + } + } + }); + } + + /// The smallest box around every finite point of ; false without any. + internal static bool TryGetPointBounds(GlyphStroke[] strokes, out Vector2 minimum, out Vector2 maximum) + { + minimum = Vector2.positiveInfinity; + maximum = Vector2.negativeInfinity; + foreach (GlyphStroke stroke in strokes) + { + foreach (Vector2 point in stroke.Points) + { + if (float.IsFinite(point.x) && float.IsFinite(point.y)) + { + minimum = Vector2.Min(minimum, point); + maximum = Vector2.Max(maximum, point); + } + } + } + return minimum.x <= maximum.x; + } + + /// + /// Runs on the serialized pack and applies it. By default it's an undo step of its own, + /// named ; otherwise it joins the current one. + /// + private void Edit(string name, Action edit, bool isNewStep = true) + { + if (isNewStep) + { + Undo.IncrementCurrentGroup(); + } + _serialized.Update(); + edit(); + _serialized.ApplyModifiedProperties(); + Undo.SetCurrentGroupName(name); + } + + private SerializedProperty ListOf(bool isSymbol) + { + return _serialized.FindProperty(isSymbol ? WireframeGlyphPack.SymbolsField : WireframeGlyphPack.CharactersField); + } + + private int SortedIndexOf(int character) + { + CharacterGlyph[] characters = Pack.Characters; + int index = 0; + while (index < characters.Length && characters[index].Character < character) + { + index++; + } + return index; + } + + /// Finds in the sorted list, or where it would go. + private static bool FindCharacterIn(SerializedProperty characters, int character, out int index) + { + for (index = 0; index < characters.arraySize; index++) + { + int code = characters.GetArrayElementAtIndex(index).FindPropertyRelative(CharacterGlyph.CharacterField).intValue; + if (code >= character) + { + return code == character; + } + } + return false; + } + + /// Moves the character at to where its code sorts it, and returns that index. + private static int Resort(SerializedProperty characters, int index) + { + int character = characters.GetArrayElementAtIndex(index).FindPropertyRelative(CharacterGlyph.CharacterField).intValue; + int target = 0; + for (int i = 0; i < characters.arraySize; i++) + { + if (i != index + && characters.GetArrayElementAtIndex(i).FindPropertyRelative(CharacterGlyph.CharacterField).intValue < character) + { + target++; + } + } + characters.MoveArrayElement(index, target); + return target; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs.meta b/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs.meta new file mode 100644 index 0000000..7fd4019 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPackEditing.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 851e7fbd773099ad7688cc05e118cd5b diff --git a/UnityPackage/Editor/Glyphs/GlyphReference.cs b/UnityPackage/Editor/Glyphs/GlyphReference.cs new file mode 100644 index 0000000..9a927f9 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphReference.cs @@ -0,0 +1,55 @@ +using System; + +namespace reromanlee.Wireframes.Editor +{ + /// A glyph of a pack: a character or a symbol, by its position in the pack's list of either. + internal readonly struct GlyphReference : IEquatable + { + internal static readonly GlyphReference None = new(false, -1); + + internal readonly bool IsSymbol; + internal readonly int Index; + + internal GlyphReference(bool isSymbol, int index) + { + IsSymbol = isSymbol; + Index = index; + } + + internal bool IsNone + { + get => Index < 0; + } + + public bool Equals(GlyphReference other) + { + return IsSymbol == other.IsSymbol && Index == other.Index; + } + + public override bool Equals(object other) + { + return other is GlyphReference glyph && Equals(glyph); + } + + public override int GetHashCode() + { + return IsSymbol ? ~Index : Index; + } + + /// The glyph's lines in , read straight from the pack. + internal GlyphStroke[] StrokesIn(WireframeGlyphPack pack) + { + if (IsSymbol) + { + return Index < pack.Symbols.Length ? pack.Symbols[Index].Strokes : Array.Empty(); + } + return Index < pack.Characters.Length ? pack.Characters[Index].Strokes : Array.Empty(); + } + + /// True when the glyph is still in , which edits and undo can change. + internal bool IsIn(WireframeGlyphPack pack) + { + return Index >= 0 && Index < (IsSymbol ? pack.Symbols.Length : pack.Characters.Length); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphReference.cs.meta b/UnityPackage/Editor/Glyphs/GlyphReference.cs.meta new file mode 100644 index 0000000..26ef070 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphReference.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9c95d146455117243b0c12c75bcb4956 diff --git a/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs b/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs new file mode 100644 index 0000000..c5b6f8f --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs @@ -0,0 +1,207 @@ +using NUnit.Framework; +using reromanlee.Wireframes.Editor; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.Wireframes.Tests +{ + /// The edits the Glyph Editor makes to a pack, and how it names and reads characters. + public class GlyphPackEditingTests + { + private WireframeGlyphPack _pack; + private GlyphPackEditing _editing; + + [SetUp] + public void CreatePack() + { + _pack = ScriptableObject.CreateInstance(); + _editing = new GlyphPackEditing(_pack); + } + + [TearDown] + public void DestroyPack() + { + Undo.ClearUndo(_pack); + Object.DestroyImmediate(_pack); + } + + [Test] + public void AddedCharacters_StaySortedByCode() + { + _editing.AddCharacter('C'); + GlyphReference a = _editing.AddCharacter('A'); + _editing.AddCharacter('B'); + + Assert.That(a.Index, Is.Zero); + Assert.That(CharacterCodes(), Is.EqualTo(new[] { 'A', 'B', 'C' })); + } + + [Test] + public void AddMissingAscii_AddsEveryPrintableCharacterThePackLacks() + { + _editing.AddCharacter('A'); + + Assert.That(_editing.AddMissingAscii(), Is.EqualTo(94)); + Assert.That(_editing.AddMissingAscii(), Is.Zero); + Assert.That(_pack.Characters[0].Character, Is.EqualTo(' ')); + Assert.That(_pack.Characters[94].Character, Is.EqualTo('~')); + } + + [Test] + public void ChangedCharacter_MovesWhereItsCodeSortsIt() + { + GlyphReference a = _editing.AddCharacter('A'); + _editing.AddCharacter('B'); + _editing.AddCharacter('C'); + + GlyphReference moved = _editing.ChangeCharacter(a, 'D'); + + Assert.That(moved.Index, Is.EqualTo(2)); + Assert.That(CharacterCodes(), Is.EqualTo(new[] { 'B', 'C', 'D' })); + } + + [Test] + public void Points_AreAddedMovedReorderedAndDeleted() + { + GlyphReference glyph = _editing.AddCharacter('A'); + int stroke = _editing.AddStroke(glyph, new Vector2(0f, 0f)); + _editing.InsertPoint(glyph, stroke, 1, new Vector2(1f, 1f)); + _editing.InsertPoint(glyph, stroke, 1, new Vector2(0.5f, 0.5f)); + + Assert.That(PointsOf(glyph), Is.EqualTo(new[] { Vector2.zero, new Vector2(0.5f, 0.5f), Vector2.one })); + + _editing.MovePoint(glyph, stroke, 1, new Vector2(0.5f, 0.25f)); + _editing.MovePointInOrder(glyph, stroke, 2, 0); + Assert.That(PointsOf(glyph), Is.EqualTo(new[] { Vector2.one, Vector2.zero, new Vector2(0.5f, 0.25f) })); + + _editing.SetClosed(glyph, stroke, true); + _editing.DeletePoint(glyph, stroke, 0); + Assert.That(PointsOf(glyph), Is.EqualTo(new[] { Vector2.zero, new Vector2(0.5f, 0.25f) })); + Assert.That(glyph.StrokesIn(_pack)[0].IsClosed, Is.True); + } + + [Test] + public void DeletingTheLastPoint_DeletesItsStroke() + { + GlyphReference glyph = _editing.AddCharacter('A'); + int stroke = _editing.AddStroke(glyph, new Vector2(0.5f, 0.5f)); + + _editing.DeletePoint(glyph, stroke, 0); + + Assert.That(glyph.StrokesIn(_pack), Is.Empty); + } + + [Test] + public void Center_MovesTheGlyphToTheMiddleOfItsBox() + { + GlyphReference glyph = _editing.AddCharacter('A'); + int stroke = _editing.AddStroke(glyph, new Vector2(0f, 0.25f)); + _editing.InsertPoint(glyph, stroke, 1, new Vector2(0.4f, 0.55f)); + + _editing.Center(glyph, true); + _editing.Center(glyph, false); + + Vector2[] points = PointsOf(glyph); + Assert.That(points[0].x, Is.EqualTo(0.3f).Within(1e-6f)); + Assert.That(points[1].x, Is.EqualTo(0.7f).Within(1e-6f)); + Assert.That(points[0].y, Is.EqualTo(0.35f).Within(1e-6f)); + Assert.That(points[1].y, Is.EqualTo(0.65f).Within(1e-6f)); + } + + [Test] + public void DuplicatedSymbol_FollowsItsOriginalWithTheSameStrokes() + { + GlyphReference heart = _editing.AddSymbol("Heart"); + _editing.AddSymbol("Star"); + _editing.AddStroke(heart, new Vector2(0.5f, 0.5f)); + + GlyphReference copy = _editing.DuplicateSymbol(heart, "HeartCopy"); + + Assert.That(copy.Index, Is.EqualTo(1)); + Assert.That(_pack.Symbols[1].Keyword, Is.EqualTo("HeartCopy")); + Assert.That(_pack.Symbols[1].Strokes[0].Points[0], Is.EqualTo(new Vector2(0.5f, 0.5f))); + Assert.That(_pack.Symbols[2].Keyword, Is.EqualTo("Star")); + } + + [Test] + public void Undo_TakesBackOneEditAtATime() + { + GlyphReference glyph = _editing.AddCharacter('A'); + _editing.AddStroke(glyph, new Vector2(0.5f, 0.5f)); + + Undo.PerformUndo(); + Assert.That(_pack.Characters, Has.Length.EqualTo(1)); + Assert.That(glyph.StrokesIn(_pack), Is.Empty); + + Undo.PerformUndo(); + Assert.That(_pack.Characters, Is.Empty); + } + + [Test] + public void CopiedStrokes_PasteIntoAnotherGlyph() + { + string clipboard = EditorGUIUtility.systemCopyBuffer; + try + { + GlyphReference source = _editing.AddCharacter('A'); + int stroke = _editing.AddStroke(source, new Vector2(0.25f, 0.25f)); + _editing.InsertPoint(source, stroke, 1, new Vector2(0.75f, 0.25f)); + GlyphClipboard.Copy(source.StrokesIn(_pack)); + + GlyphReference target = _editing.AddCharacter('B'); + Assert.That(GlyphClipboard.TryPaste(out GlyphStroke[] strokes), Is.True); + _editing.AddStrokes(target, strokes); + + Assert.That(PointsOf(target), Is.EqualTo(new[] { new Vector2(0.25f, 0.25f), new Vector2(0.75f, 0.25f) })); + EditorGUIUtility.systemCopyBuffer = "Something else"; + Assert.That(GlyphClipboard.TryPaste(out _), Is.False); + } + finally + { + EditorGUIUtility.systemCopyBuffer = clipboard; + } + } + + [TestCase("A", 'A')] + [TestCase(" ", ' ')] + [TestCase("U+0416", 0x416)] + [TestCase(" u+7e ", '~')] + public void TypedCharacters_AreReadAsThemselvesOrTheirCode(string text, int character) + { + Assert.That(GlyphNames.Parse(text, out int parsed), Is.Null); + Assert.That(parsed, Is.EqualTo(character)); + } + + [TestCase("")] + [TestCase("AB")] + [TestCase("U+0007")] + [TestCase("U+D800")] + public void OtherInput_IsRejected(string text) + { + Assert.That(GlyphNames.Parse(text, out _), Is.Not.Null); + } + + [Test] + public void Characters_AreNamedAsTheyLook() + { + Assert.That(GlyphNames.CharacterOf('A'), Is.EqualTo("A")); + Assert.That(GlyphNames.CharacterOf(' '), Is.EqualTo("Space")); + Assert.That(GlyphNames.CodeOf('A'), Is.EqualTo("U+0041")); + } + + private char[] CharacterCodes() + { + char[] codes = new char[_pack.Characters.Length]; + for (int i = 0; i < codes.Length; i++) + { + codes[i] = (char)_pack.Characters[i].Character; + } + return codes; + } + + private Vector2[] PointsOf(GlyphReference glyph) + { + return glyph.StrokesIn(_pack)[0].Points; + } + } +} diff --git a/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs.meta b/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs.meta new file mode 100644 index 0000000..f0c7e1f --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphPackEditingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 922298cb8307263dc7e68944a2645eb2 From db2196f9da6ece5c283533ad04fd82462d049964 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:40:13 +0300 Subject: [PATCH 11/30] feature: glyph gallery --- UnityPackage/Editor/Glyphs/GlyphAssets.cs | 72 +++ .../Editor/Glyphs/GlyphAssets.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphDrawing.cs | 92 ++++ .../Editor/Glyphs/GlyphDrawing.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphEditor.uss | 202 +++++++ .../Editor/Glyphs/GlyphEditor.uss.meta | 11 + UnityPackage/Editor/Glyphs/GlyphEditorPane.cs | 72 +++ .../Editor/Glyphs/GlyphEditorPane.cs.meta | 2 + .../Editor/Glyphs/GlyphEditorWindow.cs | 504 ++++++++++++++++++ .../Editor/Glyphs/GlyphEditorWindow.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphGallery.cs | 356 +++++++++++++ .../Editor/Glyphs/GlyphGallery.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs | 107 ++++ .../Editor/Glyphs/GlyphKeyPopup.cs.meta | 2 + .../Editor/Glyphs/GlyphPackSettingsPopup.cs | 94 ++++ .../Glyphs/GlyphPackSettingsPopup.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphPreview.cs | 50 ++ .../Editor/Glyphs/GlyphPreview.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphTile.cs | 58 ++ UnityPackage/Editor/Glyphs/GlyphTile.cs.meta | 2 + .../Tests/Editor/GlyphEditorWindowTests.cs | 71 +++ .../Editor/GlyphEditorWindowTests.cs.meta | 2 + 22 files changed, 1709 insertions(+) create mode 100644 UnityPackage/Editor/Glyphs/GlyphAssets.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphAssets.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphDrawing.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphDrawing.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditor.uss create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditor.uss.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditorPane.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditorPane.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphGallery.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphGallery.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphPreview.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphPreview.cs.meta create mode 100644 UnityPackage/Editor/Glyphs/GlyphTile.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphTile.cs.meta create mode 100644 UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs create mode 100644 UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs.meta diff --git a/UnityPackage/Editor/Glyphs/GlyphAssets.cs b/UnityPackage/Editor/Glyphs/GlyphAssets.cs new file mode 100644 index 0000000..e729ccb --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphAssets.cs @@ -0,0 +1,72 @@ +using System.IO; +using UnityEditor; +using UnityEngine.UIElements; +using Object = UnityEngine.Object; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; +using PackageSource = UnityEditor.PackageManager.PackageSource; + +namespace reromanlee.Wireframes.Editor +{ + /// The Glyph Editor's dealings with assets: which ones it can edit, copies to edit, and its stylesheet. + internal static class GlyphAssets + { + private const string StyleSheetPath = "Packages/com.reromanlee.wireframes/Editor/Glyphs/GlyphEditor.uss"; + + private static StyleSheet _styleSheet; + + /// The Glyph Editor's stylesheet, or null if it's missing, which leaves the editor unstyled but working. + internal static StyleSheet StyleSheet + { + get + { + if (_styleSheet == null) + { + _styleSheet = AssetDatabase.LoadAssetAtPath(StyleSheetPath); + } + return _styleSheet; + } + } + + /// + /// True when can't be changed: it's in a package installed from a registry, git or a + /// tarball, which Unity keeps read-only, or version control won't let it be edited. + /// + internal static bool IsReadOnly(Object asset) + { + string path = AssetDatabase.GetAssetPath(asset); + if (string.IsNullOrEmpty(path)) + { + return false; + } + PackageInfo package = PackageInfo.FindForAssetPath(path); + if (package != null && package.source != PackageSource.Embedded && package.source != PackageSource.Local) + { + return true; + } + return !AssetDatabase.IsOpenForEdit(path, StatusQueryOptions.UseCachedIfPossible); + } + + /// + /// Copies into the Assets folder as "My" followed by its name, to edit it there. The copy + /// forgets the original's enum settings, so generating its enum never clashes with the original's. + /// + internal static WireframeGlyphPack DuplicateToAssets(WireframeGlyphPack pack) + { + string source = AssetDatabase.GetAssetPath(pack); + string target = AssetDatabase.GenerateUniqueAssetPath($"Assets/My {Path.GetFileName(source)}"); + if (!AssetDatabase.CopyAsset(source, target)) + { + return null; + } + WireframeGlyphPack copy = AssetDatabase.LoadAssetAtPath(target); + SerializedObject serialized = new(copy); + SerializedProperty settings = serialized.FindProperty(WireframeGlyphPack.EnumSettingsField); + settings.FindPropertyRelative(GlyphEnumSettings.NameField).stringValue = string.Empty; + settings.FindPropertyRelative(GlyphEnumSettings.NamespaceField).stringValue = string.Empty; + settings.FindPropertyRelative(GlyphEnumSettings.ScriptGuidField).stringValue = string.Empty; + serialized.ApplyModifiedPropertiesWithoutUndo(); + AssetDatabase.SaveAssetIfDirty(copy); + return copy; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphAssets.cs.meta b/UnityPackage/Editor/Glyphs/GlyphAssets.cs.meta new file mode 100644 index 0000000..2521a83 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphAssets.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 490852fd60f323bc172d04b79ee47aed diff --git a/UnityPackage/Editor/Glyphs/GlyphDrawing.cs b/UnityPackage/Editor/Glyphs/GlyphDrawing.cs new file mode 100644 index 0000000..63b0398 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphDrawing.cs @@ -0,0 +1,92 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// Draws glyphs with UI Toolkit's vector API, mapping the glyph box, 0 to 1 with +Y up, onto a rectangle. + internal static class GlyphDrawing + { + /// Where a glyph-box point lands in , whose top is the box's 1. + internal static Vector2 ToBox(Vector2 point, Rect box) + { + return new Vector2(box.x + point.x * box.width, box.yMax - point.y * box.height); + } + + /// The glyph-box point at in . + internal static Vector2 FromBox(Vector2 position, Rect box) + { + return new Vector2((position.x - box.x) / box.width, (box.yMax - position.y) / box.height); + } + + /// Strokes the lines of every stroke with at least 2 points, closed strokes back to their start. + internal static void DrawStrokes(Painter2D painter, GlyphStroke[] strokes, Rect box, Color color, float width) + { + painter.strokeColor = color; + painter.lineWidth = width; + painter.lineJoin = LineJoin.Round; + painter.lineCap = LineCap.Round; + foreach (GlyphStroke stroke in strokes) + { + DrawStroke(painter, stroke, box); + } + } + + internal static void DrawStroke(Painter2D painter, GlyphStroke stroke, Rect box) + { + Vector2[] points = stroke.Points; + if (points.Length < 2) + { + return; + } + painter.BeginPath(); + painter.MoveTo(ToBox(points[0], box)); + for (int i = 1; i < points.Length; i++) + { + painter.LineTo(ToBox(points[i], box)); + } + if (stroke.IsClosed) + { + painter.ClosePath(); + } + painter.Stroke(); + } + + /// A straight line from to , in element coordinates. + internal static void DrawLine(Painter2D painter, Vector2 from, Vector2 to, Color color, float width) + { + painter.strokeColor = color; + painter.lineWidth = width; + painter.BeginPath(); + painter.MoveTo(from); + painter.LineTo(to); + painter.Stroke(); + } + + /// The outline of . + internal static void DrawRect(Painter2D painter, Rect rect, Color color, float width) + { + painter.strokeColor = color; + painter.lineWidth = width; + painter.BeginPath(); + painter.MoveTo(new Vector2(rect.xMin, rect.yMin)); + painter.LineTo(new Vector2(rect.xMax, rect.yMin)); + painter.LineTo(new Vector2(rect.xMax, rect.yMax)); + painter.LineTo(new Vector2(rect.xMin, rect.yMax)); + painter.ClosePath(); + painter.Stroke(); + } + + /// The largest square that fits , centered in it, shrunk by . + internal static Rect FittedBox(Rect rect, float margin) + { + float size = Mathf.Max(0f, Mathf.Min(rect.width, rect.height) - margin * 2f); + return new Rect(rect.center.x - size * 0.5f, rect.center.y - size * 0.5f, size, size); + } + + /// A color with times its own alpha. + internal static Color Faded(Color color, float alpha) + { + return new Color(color.r, color.g, color.b, color.a * alpha); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphDrawing.cs.meta b/UnityPackage/Editor/Glyphs/GlyphDrawing.cs.meta new file mode 100644 index 0000000..9bc3ba0 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphDrawing.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6853c7f9cb1a5f78963ebd30dfe0fede diff --git a/UnityPackage/Editor/Glyphs/GlyphEditor.uss b/UnityPackage/Editor/Glyphs/GlyphEditor.uss new file mode 100644 index 0000000..e1355eb --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditor.uss @@ -0,0 +1,202 @@ +/* The Glyph Editor window, its popups and the glyph canvas. Colors follow the editor's theme. */ + +.glyph-editor { + flex-grow: 1; +} + +.glyph-editor__pack-field { + width: 220px; + margin-right: 4px; +} + +.glyph-editor__search { + width: 160px; +} + +.glyph-editor__tile-size { + width: 90px; + margin-left: 6px; + margin-right: 6px; +} + +.glyph-editor__generate--out-of-date { + background-color: rgba(255, 180, 0, 0.35); +} + +.glyph-editor__banner { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background-color: var(--unity-colors-helpbox-background, rgba(96, 96, 96, 0.25)); + border-bottom-width: 1px; + border-color: var(--unity-colors-default-border, rgba(0, 0, 0, 0.4)); +} + +.glyph-editor__banner > Label { + flex-shrink: 1; + white-space: normal; +} + +.glyph-editor__empty { + flex-grow: 1; + align-items: center; + justify-content: center; +} + +.glyph-editor__empty > Label { + margin-bottom: 8px; + white-space: normal; + -unity-text-align: middle-center; +} + +.glyph-editor__split { + flex-grow: 1; +} + +/* Gallery */ + +.glyph-gallery { + flex-grow: 1; + min-width: 160px; +} + +.glyph-gallery__section { + margin: 4px 6px; +} + +.glyph-gallery__section > .unity-foldout__toggle { + -unity-font-style: bold; +} + +.glyph-gallery__grid { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + padding: 2px 0 6px 0; +} + +.glyph-tile { + margin: 2px; + padding: 3px; + border-radius: 3px; + border-width: 1px; + border-color: rgba(127, 127, 127, 0.15); + background-color: rgba(127, 127, 127, 0.08); + color: var(--unity-colors-default-text, #C4C4C4); +} + +.glyph-tile:hover { + background-color: rgba(127, 127, 127, 0.18); +} + +.glyph-tile--selected { + background-color: var(--unity-colors-highlight-background, #2C5D87); + border-color: var(--unity-colors-highlight-background-hover, #3A72B0); + color: var(--unity-colors-highlight-text, #FFFFFF); +} + +.glyph-tile--dragged { + opacity: 0.4; +} + +.glyph-preview { + width: 100%; +} + +.glyph-tile__name { + -unity-text-align: middle-center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0; + margin: 0; +} + +.glyph-tile__code { + -unity-text-align: middle-center; + font-size: 9px; + opacity: 0.6; + padding: 0; + margin: 0; +} + +.glyph-gallery__add { + min-width: 40px; + height: 40px; + margin: 2px; + align-self: center; + font-size: 14px; +} + +.glyph-gallery__drop-marker { + position: absolute; + width: 3px; + background-color: var(--unity-colors-highlight-background-hover, #3A72B0); +} + +/* Editor pane */ + +.glyph-editor-pane { + flex-grow: 1; + min-width: 300px; + border-left-width: 1px; + border-color: var(--unity-colors-default-border, rgba(0, 0, 0, 0.4)); +} + +.glyph-editor-pane__header { + flex-direction: row; + align-items: center; + padding: 4px 6px; + border-bottom-width: 1px; + border-color: var(--unity-colors-default-border, rgba(0, 0, 0, 0.4)); +} + +.glyph-editor-pane__title { + font-size: 16px; + -unity-font-style: bold; +} + +.glyph-editor-pane__code { + margin-left: 8px; + opacity: 0.6; +} + +.glyph-editor-pane__close { + width: 22px; + height: 20px; + font-size: 14px; +} + +.glyph-editor-pane__body { + flex-grow: 1; + flex-direction: row; +} + +/* Popups */ + +.glyph-popup { + padding: 8px; +} + +.glyph-popup > #Title { + -unity-font-style: bold; + margin-bottom: 4px; +} + +.glyph-popup__problem { + min-height: 16px; + white-space: normal; + color: rgb(255, 196, 0); + margin: 2px 3px; +} + +.glyph-popup__hint { + font-size: 10px; + opacity: 0.6; + margin: 0 3px 4px 3px; +} + +.glyph-popup__field > .unity-base-field__label { + min-width: 120px; +} diff --git a/UnityPackage/Editor/Glyphs/GlyphEditor.uss.meta b/UnityPackage/Editor/Glyphs/GlyphEditor.uss.meta new file mode 100644 index 0000000..1e1f4a1 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditor.uss.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3c56d44a6e5b9344da30b423ae8db0d7 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 diff --git a/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs b/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs new file mode 100644 index 0000000..438ee37 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs @@ -0,0 +1,72 @@ +using System; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// The open glyph: its name and a button that closes it, above the canvas that edits it. + internal sealed class GlyphEditorPane : VisualElement + { + private readonly Label _title = new(); + private readonly Label _code = new(); + + internal GlyphEditorPane() + { + AddToClassList("glyph-editor-pane"); + VisualElement header = new(); + header.AddToClassList("glyph-editor-pane__header"); + _title.AddToClassList("glyph-editor-pane__title"); + _code.AddToClassList("glyph-editor-pane__code"); + Button close = new(() => CloseClicked?.Invoke()) { text = "×", tooltip = "Close the glyph (Esc)." }; + close.AddToClassList("glyph-editor-pane__close"); + header.Add(_title); + header.Add(_code); + header.Add(new VisualElement { style = { flexGrow = 1f } }); + header.Add(close); + Add(header); + Body = new VisualElement(); + Body.AddToClassList("glyph-editor-pane__body"); + Add(Body); + } + + internal event Action CloseClicked; + + /// Where the canvas and the glyph's details go, below the header. + internal VisualElement Body { get; } + + internal GlyphPackEditing Editing { get; private set; } + + internal GlyphReference Glyph { get; private set; } = GlyphReference.None; + + internal bool IsReadOnly { get; private set; } + + /// Shows of the pack that edits. + internal void Show(GlyphPackEditing editing, GlyphReference glyph, bool isReadOnly) + { + Editing = editing; + Glyph = glyph; + IsReadOnly = isReadOnly; + Refresh(); + } + + /// Brings the pane in line with the pack after an edit or undo. + internal void Refresh() + { + if (Editing == null || !Glyph.IsIn(Editing.Pack)) + { + return; + } + WireframeGlyphPack pack = Editing.Pack; + if (Glyph.IsSymbol) + { + _title.text = pack.Symbols[Glyph.Index].Keyword; + _code.text = "Symbol"; + } + else + { + int character = pack.Characters[Glyph.Index].Character; + _title.text = GlyphNames.CharacterOf(character); + _code.text = GlyphNames.CodeOf(character); + } + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs.meta b/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs.meta new file mode 100644 index 0000000..c0a8f12 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditorPane.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee329666a54b7f744fbc919ce7e7677b diff --git a/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs b/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs new file mode 100644 index 0000000..ed811b3 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs @@ -0,0 +1,504 @@ +using System; +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEditor.ShortcutManagement; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// The Glyph Editor: a gallery of a glyph pack's characters and symbols, which splits to edit the glyph opened from + /// it. Edits go straight into the pack, with undo, and every text and symbol drawn with the pack follows at once. + /// + internal sealed class GlyphEditorWindow : EditorWindow + { + private const string Title = "Glyph Editor"; + private const float EditorPaneWidth = 560f; + private const float MinimumTileSize = 40f; + private const float MaximumTileSize = 128f; + + [SerializeField] private WireframeGlyphPack _pack; + [SerializeField] private bool _isSymbolOpen; + [SerializeField] private int _openCharacter = -1; + [SerializeField] private string _openKeyword; + [SerializeField] private float _tileSize = 64f; + [SerializeField] private bool _sortsSymbolsByName; + + private GlyphPackEditing _editing; + private bool _isReadOnly; + private int _seenVersion = -1; + + private ObjectField _packField; + private ToolbarButton _settingsButton; + private ToolbarButton _generateButton; + private ToolbarSearchField _search; + private ToolbarToggle _sortToggle; + private VisualElement _readOnlyBanner; + private VisualElement _emptyState; + private TwoPaneSplitView _split; + private GlyphGallery _gallery; + private GlyphEditorPane _pane; + + /// The glyph open in the editor pane, or none while only the gallery shows. + internal GlyphReference OpenGlyph { get; private set; } = GlyphReference.None; + + internal WireframeGlyphPack Pack + { + get => _pack; + } + + [MenuItem("Window/Wireframes/Glyph Editor")] + internal static GlyphEditorWindow ShowWindow() + { + GlyphEditorWindow window = GetWindow(); + window.titleContent = new GUIContent(Title); + window.minSize = new Vector2(420f, 320f); + window.Show(); + return window; + } + + /// Shows the Glyph Editor with in its gallery. + internal static GlyphEditorWindow Open(WireframeGlyphPack pack) + { + GlyphEditorWindow window = ShowWindow(); + window.SetPack(pack); + return window; + } + +#if UNITY_6000_3_OR_NEWER + [OnOpenAsset] + private static bool OnOpenAsset(EntityId entityId, int line) + { + return OpenIfPack(EditorUtility.EntityIdToObject(entityId)); + } +#else + [OnOpenAsset] + private static bool OnOpenAsset(int instanceId, int line) + { + return OpenIfPack(EditorUtility.InstanceIDToObject(instanceId)); + } +#endif + + [Shortcut("Wireframes/Glyph Editor/Save Pack", typeof(GlyphEditorWindow), KeyCode.S, ShortcutModifiers.Action)] + private static void SavePack(ShortcutArguments arguments) + { + ((GlyphEditorWindow)arguments.context).SaveChanges(); + } + + /// Puts in the gallery and closes the glyph open from the previous one. + internal void SetPack(WireframeGlyphPack pack) + { + if (pack == _pack && _editing != null) + { + return; + } + _pack = pack; + _packField?.SetValueWithoutNotify(pack); + OpenGlyph = GlyphReference.None; + _openCharacter = -1; + _openKeyword = null; + Load(); + } + + /// Opens in the editor pane, or closes the pane for none. + internal void Open(GlyphReference glyph) + { + OpenGlyph = glyph.IsNone || _pack == null || !glyph.IsIn(_pack) ? GlyphReference.None : glyph; + _isSymbolOpen = OpenGlyph.IsSymbol; + _openCharacter = !OpenGlyph.IsNone && !OpenGlyph.IsSymbol ? _pack.Characters[OpenGlyph.Index].Character : -1; + _openKeyword = !OpenGlyph.IsNone && OpenGlyph.IsSymbol ? _pack.Symbols[OpenGlyph.Index].Keyword : null; + _gallery.Selected = OpenGlyph; + if (OpenGlyph.IsNone) + { + _split.CollapseChild(1); + return; + } + _split.UnCollapse(); + _pane.Show(_editing, OpenGlyph, _isReadOnly); + } + + public override void SaveChanges() + { + if (_pack != null) + { + AssetDatabase.SaveAssetIfDirty(_pack); + } + base.SaveChanges(); + } + + public override void DiscardChanges() + { + // Reading the pack from disk again drops what wasn't saved, in every scene that draws with it too. + if (_pack != null && EditorUtility.IsDirty(_pack)) + { + AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(_pack), ImportAssetOptions.ForceUpdate); + } + base.DiscardChanges(); + } + + private void CreateGUI() + { + VisualElement root = rootVisualElement; + if (GlyphAssets.StyleSheet != null) + { + root.styleSheets.Add(GlyphAssets.StyleSheet); + } + root.AddToClassList("glyph-editor"); + root.Add(CreateToolbar()); + + _readOnlyBanner = new VisualElement(); + _readOnlyBanner.AddToClassList("glyph-editor__banner"); + _readOnlyBanner.Add(new Label( + "This pack is part of an installed package, so it can't be changed. Duplicate it to edit a copy.")); + _readOnlyBanner.Add(new Button(DuplicateToAssets) { text = "Duplicate to Assets" }); + root.Add(_readOnlyBanner); + + _emptyState = new VisualElement(); + _emptyState.AddToClassList("glyph-editor__empty"); + _emptyState.Add(new Label("Open a glyph pack: double-click one in the Project window, or pick one above.")); + _emptyState.Add(new Button(CreatePack) { text = "New Glyph Pack…" }); + root.Add(_emptyState); + + _gallery = new GlyphGallery { TileSize = _tileSize, SortsSymbolsByName = _sortsSymbolsByName }; + _gallery.GlyphClicked += Open; + _gallery.AddCharacterClicked += AskForNewCharacter; + _gallery.AddMissingAsciiClicked += () => Edit(() => _editing.AddMissingAscii()); + _gallery.AddSymbolClicked += AskForNewSymbol; + _gallery.SymbolMoved += (from, index) => Edit(() => _editing.MoveSymbol(from, index)); + _gallery.TileMenuBuilding += BuildTileMenu; + + _pane = new GlyphEditorPane(); + _pane.CloseClicked += () => Open(GlyphReference.None); + + _split = new TwoPaneSplitView(1, EditorPaneWidth, TwoPaneSplitViewOrientation.Horizontal); + _split.AddToClassList("glyph-editor__split"); + _split.Add(_gallery); + _split.Add(_pane); + root.Add(_split); + + root.RegisterCallback(OnKeyDown); + Load(); + } + + private VisualElement CreateToolbar() + { + Toolbar toolbar = new(); + _packField = new ObjectField { objectType = typeof(WireframeGlyphPack), allowSceneObjects = false, value = _pack }; + _packField.AddToClassList("glyph-editor__pack-field"); + _packField.RegisterValueChangedCallback(change => SetPack(change.newValue as WireframeGlyphPack)); + toolbar.Add(_packField); + + _settingsButton = new ToolbarButton(() => GlyphPackSettingsPopup.Show(_settingsButton, _pack, _isReadOnly)) + { + text = "Pack Settings", tooltip = "The pack's enum, space width, guides and grid." + }; + toolbar.Add(_settingsButton); + _generateButton = new ToolbarButton(GenerateEnum) { text = "Generate Enum" }; + toolbar.Add(_generateButton); + + toolbar.Add(new ToolbarSpacer { flex = true }); + _search = new ToolbarSearchField(); + _search.AddToClassList("glyph-editor__search"); + _search.RegisterValueChangedCallback(change => _gallery.Filter = change.newValue); + toolbar.Add(_search); + _sortToggle = new ToolbarToggle { text = "A–Z", tooltip = "List symbols by keyword instead of pack order.", value = _sortsSymbolsByName }; + _sortToggle.RegisterValueChangedCallback(change => + { + _sortsSymbolsByName = change.newValue; + _gallery.SortsSymbolsByName = change.newValue; + }); + toolbar.Add(_sortToggle); + Slider tileSize = new(MinimumTileSize, MaximumTileSize) { value = _tileSize, tooltip = "Size of the tiles." }; + tileSize.AddToClassList("glyph-editor__tile-size"); + tileSize.RegisterValueChangedCallback(change => + { + _tileSize = change.newValue; + _gallery.TileSize = change.newValue; + }); + toolbar.Add(tileSize); + return toolbar; + } + + /// Starts editing and shows it, opening the glyph that was open before a reload. + private void Load() + { + if (_gallery == null) + { + return; + } + _editing = _pack != null ? new GlyphPackEditing(_pack) : null; + _isReadOnly = _pack != null && GlyphAssets.IsReadOnly(_pack); + SetShown(_emptyState, _pack == null); + SetShown(_split, _pack != null); + _gallery.Show(_pack, _isReadOnly); + Open(FindOpenGlyph()); + Refresh(); + } + + private void OnEnable() + { + Undo.undoRedoPerformed += Refresh; + } + + private void OnDisable() + { + Undo.undoRedoPerformed -= Refresh; + } + + private void OnInspectorUpdate() + { + // The pack can change outside the window too: in the Inspector, by a reimport, or in another window. + if (_seenVersion != GlyphEdits.Version) + { + Refresh(); + } + hasUnsavedChanges = _pack != null && EditorUtility.IsDirty(_pack); + saveChangesMessage = _pack != null ? $"The glyph pack '{_pack.name}' has changes that aren't saved." : null; + } + + /// Brings everything in line with the pack after an edit, undo or change made elsewhere. + private void Refresh() + { + _seenVersion = GlyphEdits.Version; + if (_gallery == null) + { + return; + } + if (_pack == null) + { + if (_editing != null) + { + Load(); + } + return; + } + _editing?.Serialized.Update(); + _gallery.Refresh(); + GlyphReference open = FindOpenGlyph(); + if (!open.Equals(OpenGlyph)) + { + Open(open); + } + else if (!open.IsNone) + { + _pane.Refresh(); + } + SetShown(_readOnlyBanner, _isReadOnly); + RefreshGenerateButton(); + } + + private void RefreshGenerateButton() + { + bool hasSymbols = _pack.Symbols.Length > 0 || !string.IsNullOrEmpty(_pack.EnumSettings.ScriptGuid); + _generateButton.SetEnabled(hasSymbols && !_isReadOnly); + bool isOutOfDate = hasSymbols && !_isReadOnly && GlyphEnumGenerator.IsOutOfDate(_pack); + _generateButton.EnableInClassList("glyph-editor__generate--out-of-date", isOutOfDate); + _generateButton.tooltip = isOutOfDate + ? "The symbols changed since the enum was generated, so the enum is out of date." + : "Writes the pack's symbol enum, which names each symbol in code."; + } + + /// Finds the glyph that was open by its character or keyword, which edits and undo can move. + private GlyphReference FindOpenGlyph() + { + if (_editing == null) + { + return GlyphReference.None; + } + if (_isSymbolOpen) + { + return string.IsNullOrEmpty(_openKeyword) ? GlyphReference.None : _editing.FindSymbol(_openKeyword); + } + return _openCharacter < 0 ? GlyphReference.None : _editing.FindCharacter(_openCharacter); + } + + /// Runs an edit, then refreshes, so the gallery and the pane show its result at once. + private void Edit(Action edit) + { + if (_editing == null || _isReadOnly) + { + return; + } + edit(); + Refresh(); + } + + private void AskForNewCharacter(VisualElement anchor) + { + GlyphKeyPopup.Show(anchor, "Add a character, or its code", string.Empty, "Add", FindNewCharacterProblem, text => + { + GlyphNames.Parse(text, out int character); + GlyphReference added = GlyphReference.None; + Edit(() => added = _editing.AddCharacter(character)); + Open(added); + }); + } + + private void AskForNewSymbol(VisualElement anchor) + { + GlyphKeyPopup.Show(anchor, "Add a symbol named", string.Empty, "Add", FindNewKeywordProblem, keyword => + { + GlyphReference added = GlyphReference.None; + Edit(() => added = _editing.AddSymbol(keyword)); + Open(added); + }); + } + + private void BuildTileMenu(GlyphReference glyph, VisualElement tile, DropdownMenu menu) + { + DropdownMenuAction.Status editable = _isReadOnly ? DropdownMenuAction.Status.Disabled : DropdownMenuAction.Status.Normal; + menu.AppendAction("Open", _ => Open(glyph)); + menu.AppendSeparator(); + if (glyph.IsSymbol) + { + string keyword = _pack.Symbols[glyph.Index].Keyword; + menu.AppendAction("Rename…", _ => GlyphKeyPopup.Show(tile, "Rename the symbol to", keyword, "Rename", + text => text == keyword ? null : FindNewKeywordProblem(text), + text => Edit(() => _editing.Rename(glyph, text))), editable); + menu.AppendAction("Duplicate…", _ => GlyphKeyPopup.Show(tile, "Name the copy", UniqueKeyword(keyword + "Copy"), + "Duplicate", FindNewKeywordProblem, text => + { + GlyphReference copy = GlyphReference.None; + Edit(() => copy = _editing.DuplicateSymbol(glyph, text)); + Open(copy); + }), editable); + } + else + { + menu.AppendAction("Change Character…", _ => GlyphKeyPopup.Show(tile, "Give the glyph to the character", + string.Empty, "Change", FindNewCharacterProblem, text => + { + GlyphNames.Parse(text, out int character); + GlyphReference moved = GlyphReference.None; + Edit(() => moved = _editing.ChangeCharacter(glyph, character)); + Open(moved); + }), editable); + menu.AppendAction("Duplicate…", _ => GlyphKeyPopup.Show(tile, "Copy the glyph to the character", + string.Empty, "Duplicate", FindNewCharacterProblem, text => + { + GlyphNames.Parse(text, out int character); + GlyphReference copy = GlyphReference.None; + Edit(() => copy = _editing.DuplicateCharacter(glyph, character)); + Open(copy); + }), editable); + } + menu.AppendAction("Copy Strokes", _ => GlyphClipboard.Copy(glyph.StrokesIn(_pack)), + glyph.StrokesIn(_pack).Length > 0 ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled); + menu.AppendAction("Paste Strokes", _ => + { + if (GlyphClipboard.TryPaste(out GlyphStroke[] strokes)) + { + Edit(() => _editing.AddStrokes(glyph, strokes)); + } + }, action => !_isReadOnly && GlyphClipboard.TryPaste(out _) ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled); + menu.AppendSeparator(); + menu.AppendAction("Delete", _ => + { + if (glyph.Equals(OpenGlyph)) + { + Open(GlyphReference.None); + } + Edit(() => _editing.Delete(glyph)); + }, editable); + } + + private string FindNewCharacterProblem(string text) + { + string problem = GlyphNames.Parse(text, out int character); + if (problem != null) + { + return problem; + } + return _editing.FindCharacter(character).IsNone ? null : $"The pack already has {GlyphNames.CharacterOf(character)}."; + } + + private string FindNewKeywordProblem(string keyword) + { + string problem = SymbolKeys.FindProblem(keyword); + if (problem != null) + { + return problem; + } + return _editing.FindSymbol(keyword).IsNone ? null : $"The pack already has {keyword}."; + } + + private string UniqueKeyword(string keyword) + { + string unique = keyword; + for (int number = 2; !_editing.FindSymbol(unique).IsNone; number++) + { + unique = keyword + number; + } + return unique; + } + + private void GenerateEnum() + { + string problem = GlyphEnumGenerator.FindProblem(_pack); + if (problem != null) + { + EditorUtility.DisplayDialog("Can't generate the enum", problem, "OK"); + return; + } + try + { + GlyphEnumGenerator.Write(_pack); + } + catch (Exception exception) when (exception is InvalidOperationException or System.IO.IOException + or UnauthorizedAccessException) + { + EditorUtility.DisplayDialog("Can't generate the enum", exception.Message, "OK"); + } + RefreshGenerateButton(); + } + + private void DuplicateToAssets() + { + WireframeGlyphPack copy = GlyphAssets.DuplicateToAssets(_pack); + if (copy != null) + { + EditorGUIUtility.PingObject(copy); + SetPack(copy); + } + } + + private void CreatePack() + { + string path = EditorUtility.SaveFilePanelInProject("New Glyph Pack", "Glyph Pack", "asset", "Where to save the glyph pack."); + if (string.IsNullOrEmpty(path)) + { + return; + } + WireframeGlyphPack pack = CreateInstance(); + AssetDatabase.CreateAsset(pack, path); + SetPack(pack); + } + + private void OnKeyDown(KeyDownEvent keyEvent) + { + if (keyEvent.keyCode == KeyCode.Escape && !OpenGlyph.IsNone) + { + Open(GlyphReference.None); + keyEvent.StopPropagation(); + } + } + + private static bool OpenIfPack(Object asset) + { + if (asset is not WireframeGlyphPack pack) + { + return false; + } + Open(pack); + return true; + } + + private static void SetShown(VisualElement element, bool isShown) + { + element.style.display = isShown ? DisplayStyle.Flex : DisplayStyle.None; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs.meta b/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs.meta new file mode 100644 index 0000000..2a75a26 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphEditorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e23150592adb1a0779b524834a7f2b44 diff --git a/UnityPackage/Editor/Glyphs/GlyphGallery.cs b/UnityPackage/Editor/Glyphs/GlyphGallery.cs new file mode 100644 index 0000000..8318f26 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphGallery.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// A pack's glyphs in two sections, characters sorted by code and symbols in pack order or by name, each a grid of + /// tiles that wraps to the width, with tiles that add glyphs at the end. Symbols in pack order can be dragged into + /// another order, which is the order of their enum's members. + /// + internal sealed class GlyphGallery : ScrollView + { + private const float DragThreshold = 6f; + + private readonly Foldout _characterSection = new() { value = true }; + private readonly Foldout _symbolSection = new() { value = true }; + private readonly VisualElement _characterGrid = new(); + private readonly VisualElement _symbolGrid = new(); + private readonly Button _addCharacter; + private readonly Button _addMissingAscii; + private readonly Button _addSymbol; + private readonly VisualElement _dropMarker = new(); + private readonly List _characterTiles = new(); + private readonly List _symbolTiles = new(); + private readonly List _order = new(); + + private WireframeGlyphPack _pack; + private bool _isReadOnly; + private string _filter = string.Empty; + private float _tileSize = 64f; + private bool _sortsSymbolsByName; + private bool _lacksAscii; + private GlyphReference _selected = GlyphReference.None; + + private GlyphTile _pressedTile; + private Vector2 _pressPosition; + private bool _isDragging; + private int _dropIndex = -1; + + internal GlyphGallery() + { + AddToClassList("glyph-gallery"); + _characterSection.AddToClassList("glyph-gallery__section"); + _symbolSection.AddToClassList("glyph-gallery__section"); + _characterGrid.AddToClassList("glyph-gallery__grid"); + _symbolGrid.AddToClassList("glyph-gallery__grid"); + _dropMarker.AddToClassList("glyph-gallery__drop-marker"); + _dropMarker.pickingMode = PickingMode.Ignore; + _addCharacter = CreateAddButton("+", "Add a character.", () => AddCharacterClicked?.Invoke(_addCharacter)); + _addMissingAscii = CreateAddButton( + "+ ASCII", "Add an empty glyph for every printable ASCII character the pack lacks.", + () => AddMissingAsciiClicked?.Invoke()); + _addSymbol = CreateAddButton("+", "Add a symbol.", () => AddSymbolClicked?.Invoke(_addSymbol)); + _characterSection.Add(_characterGrid); + _symbolSection.Add(_symbolGrid); + Add(_characterSection); + Add(_symbolSection); + } + + /// Raised when a tile is clicked, to open its glyph. + internal event Action GlyphClicked; + + /// Raised with the button to show a popup at, to add a character. + internal event Action AddCharacterClicked; + + internal event Action AddMissingAsciiClicked; + + /// Raised with the button to show a popup at, to add a symbol. + internal event Action AddSymbolClicked; + + /// Raised when a symbol is dropped elsewhere in pack order: its index, and the index it moves to. + internal event Action SymbolMoved; + + /// Fills the context menu of a tile's glyph. + internal event Action TileMenuBuilding; + + /// Text that tiles must contain to show: part of a character, a code or a keyword. + internal string Filter + { + set + { + _filter = value ?? string.Empty; + ApplyFilter(); + } + } + + /// Width of a tile's preview in pixels. + internal float TileSize + { + set + { + _tileSize = value; + foreach (GlyphTile tile in _characterTiles) + { + tile.SetSize(value); + } + foreach (GlyphTile tile in _symbolTiles) + { + tile.SetSize(value); + } + } + } + + /// True lists symbols by keyword, false in pack order, the order they can be dragged into. + internal bool SortsSymbolsByName + { + set + { + _sortsSymbolsByName = value; + Refresh(); + } + } + + /// The glyph whose tile shows as selected. + internal GlyphReference Selected + { + set + { + _selected = value; + ApplySelection(); + } + } + + internal void Show(WireframeGlyphPack pack, bool isReadOnly) + { + _pack = pack; + _isReadOnly = isReadOnly; + Refresh(); + } + + /// Brings every tile in line with the pack, reusing the tiles it has. + internal void Refresh() + { + EndPress(-1); + if (_pack == null) + { + return; + } + CharacterGlyph[] characters = _pack.Characters; + _order.Clear(); + for (int i = 0; i < characters.Length; i++) + { + _order.Add(i); + } + _order.Sort((a, b) => characters[a].Character.CompareTo(characters[b].Character)); + Fill(_characterGrid, _characterTiles, false); + _characterSection.text = $"Characters ({characters.Length})"; + _characterGrid.Add(_addCharacter); + _characterGrid.Add(_addMissingAscii); + _lacksAscii = !HasAllAscii(characters); + + SymbolGlyph[] symbols = _pack.Symbols; + _order.Clear(); + for (int i = 0; i < symbols.Length; i++) + { + _order.Add(i); + } + if (_sortsSymbolsByName) + { + _order.Sort((a, b) => string.Compare(symbols[a].Keyword, symbols[b].Keyword, StringComparison.OrdinalIgnoreCase)); + } + Fill(_symbolGrid, _symbolTiles, true); + _symbolSection.text = $"Symbols ({symbols.Length})"; + _symbolGrid.Add(_addSymbol); + + ApplyFilter(); + ApplySelection(); + } + + private void Fill(VisualElement grid, List tiles, bool isSymbol) + { + while (tiles.Count < _order.Count) + { + tiles.Add(CreateTile()); + } + tiles.RemoveRange(_order.Count, tiles.Count - _order.Count); + grid.Clear(); + for (int i = 0; i < _order.Count; i++) + { + GlyphTile tile = tiles[i]; + tile.Show(new GlyphReference(isSymbol, _order[i]), _pack); + tile.SetSize(_tileSize); + grid.Add(tile); + } + } + + private void ApplyFilter() + { + bool isFiltered = _filter.Length > 0; + foreach (GlyphTile tile in _characterTiles) + { + SetShown(tile, !isFiltered || tile.SearchText.IndexOf(_filter, StringComparison.OrdinalIgnoreCase) >= 0); + } + foreach (GlyphTile tile in _symbolTiles) + { + SetShown(tile, !isFiltered || tile.SearchText.IndexOf(_filter, StringComparison.OrdinalIgnoreCase) >= 0); + } + // Adding glyphs while searching would put them out of sight. + bool canAdd = !isFiltered && !_isReadOnly; + SetShown(_addCharacter, canAdd); + SetShown(_addMissingAscii, canAdd && _lacksAscii); + SetShown(_addSymbol, canAdd); + } + + private void ApplySelection() + { + foreach (GlyphTile tile in _characterTiles) + { + tile.EnableInClassList(GlyphTile.SelectedClass, tile.Glyph.Equals(_selected)); + } + foreach (GlyphTile tile in _symbolTiles) + { + tile.EnableInClassList(GlyphTile.SelectedClass, tile.Glyph.Equals(_selected)); + } + } + + private GlyphTile CreateTile() + { + GlyphTile tile = new(); + tile.RegisterCallback(OnTilePointerDown); + tile.RegisterCallback(OnTilePointerMove); + tile.RegisterCallback(OnTilePointerUp); + tile.RegisterCallback(_ => EndPress(-1)); + tile.AddManipulator(new ContextualMenuManipulator(menuEvent => + TileMenuBuilding?.Invoke(tile.Glyph, tile, menuEvent.menu))); + return tile; + } + + private void OnTilePointerDown(PointerDownEvent pointerEvent) + { + if (pointerEvent.button != 0) + { + return; + } + _pressedTile = (GlyphTile)pointerEvent.currentTarget; + _pressPosition = pointerEvent.position; + _isDragging = false; + _pressedTile.CapturePointer(pointerEvent.pointerId); + pointerEvent.StopPropagation(); + } + + private void OnTilePointerMove(PointerMoveEvent pointerEvent) + { + if (_pressedTile == null || !_pressedTile.HasPointerCapture(pointerEvent.pointerId)) + { + return; + } + if (!_isDragging) + { + Vector2 moved = (Vector2)pointerEvent.position - _pressPosition; + if (!CanDrag(_pressedTile) || moved.magnitude < DragThreshold) + { + return; + } + _isDragging = true; + _pressedTile.AddToClassList(GlyphTile.DraggedClass); + _symbolGrid.Add(_dropMarker); + } + MoveDropMarker(pointerEvent.position); + } + + private void OnTilePointerUp(PointerUpEvent pointerEvent) + { + if (_pressedTile == null || pointerEvent.button != 0) + { + return; + } + GlyphReference glyph = _pressedTile.Glyph; + bool wasDragging = _isDragging; + int dropIndex = _dropIndex; + EndPress(pointerEvent.pointerId); + if (!wasDragging) + { + GlyphClicked?.Invoke(glyph); + return; + } + // Dropping after itself takes the symbol out of the list first, which shifts the later ones back. + int index = dropIndex > glyph.Index ? dropIndex - 1 : dropIndex; + if (dropIndex >= 0 && index != glyph.Index) + { + SymbolMoved?.Invoke(glyph.Index, index); + } + } + + /// Puts the drop marker before or after the symbol tile under the pointer. + private void MoveDropMarker(Vector2 pointer) + { + _dropIndex = -1; + foreach (GlyphTile tile in _symbolTiles) + { + Rect bounds = tile.worldBound; + if (!bounds.Contains(pointer)) + { + continue; + } + bool isBefore = pointer.x < bounds.center.x; + _dropIndex = isBefore ? tile.Glyph.Index : tile.Glyph.Index + 1; + Rect layout = tile.layout; + _dropMarker.style.left = isBefore ? layout.xMin - 2f : layout.xMax; + _dropMarker.style.top = layout.yMin; + _dropMarker.style.height = layout.height; + break; + } + _dropMarker.style.visibility = _dropIndex >= 0 ? Visibility.Visible : Visibility.Hidden; + } + + /// Ends a press or drag, releasing , or every pointer when it's -1. + private void EndPress(int pointerId) + { + if (_pressedTile != null) + { + if (pointerId >= 0 && _pressedTile.HasPointerCapture(pointerId)) + { + _pressedTile.ReleasePointer(pointerId); + } + _pressedTile.RemoveFromClassList(GlyphTile.DraggedClass); + } + _dropMarker.RemoveFromHierarchy(); + _pressedTile = null; + _isDragging = false; + _dropIndex = -1; + } + + private bool CanDrag(GlyphTile tile) + { + return tile.Glyph.IsSymbol && !_sortsSymbolsByName && !_isReadOnly && _filter.Length == 0; + } + + private static bool HasAllAscii(CharacterGlyph[] characters) + { + int count = 0; + foreach (CharacterGlyph character in characters) + { + if (character.Character is >= 0x20 and <= 0x7E) + { + count++; + } + } + return count >= 0x7E - 0x20 + 1; + } + + private static Button CreateAddButton(string text, string tooltip, Action clicked) + { + Button button = new(clicked) { text = text, tooltip = tooltip }; + button.AddToClassList("glyph-gallery__add"); + return button; + } + + private static void SetShown(VisualElement element, bool isShown) + { + element.style.display = isShown ? DisplayStyle.Flex : DisplayStyle.None; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphGallery.cs.meta b/UnityPackage/Editor/Glyphs/GlyphGallery.cs.meta new file mode 100644 index 0000000..f7a64db --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphGallery.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26697efc314696350a2d1764c28e299f diff --git a/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs b/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs new file mode 100644 index 0000000..80d7d65 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs @@ -0,0 +1,107 @@ +using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// A small popup that asks for a character or a keyword, says what's wrong with it while it's typed, and hands it + /// over once it's valid, on Enter or its button. + /// + internal sealed class GlyphKeyPopup : PopupWindowContent + { + private readonly string _title; + private readonly string _initialText; + private readonly string _submitText; + private readonly Func _findProblem; + private readonly Action _submit; + + private TextField _field; + private Label _problem; + private Button _button; + + /// Returns why a text can't be taken, or null when it can. + internal GlyphKeyPopup( + string title, string initialText, string submitText, Func findProblem, Action submit) + { + _title = title; + _initialText = initialText ?? string.Empty; + _submitText = submitText; + _findProblem = findProblem; + _submit = submit; + } + + /// Shows a popup below . + internal static void Show( + VisualElement anchor, string title, string initialText, string submitText, Func findProblem, + Action submit) + { + UnityEditor.PopupWindow.Show(anchor.worldBound, + new GlyphKeyPopup(title, initialText, submitText, findProblem, submit)); + } + + public override Vector2 GetWindowSize() + { + return new Vector2(280f, 104f); + } + + public override void OnGUI(Rect rect) + { + } + + public override void OnOpen() + { + VisualElement root = editorWindow.rootVisualElement; + if (GlyphAssets.StyleSheet != null) + { + root.styleSheets.Add(GlyphAssets.StyleSheet); + } + root.AddToClassList("glyph-popup"); + root.Add(new Label(_title) { name = "Title" }); + _field = new TextField { value = _initialText }; + _field.RegisterValueChangedCallback(_ => Validate()); + root.RegisterCallback(OnKeyDown, TrickleDown.TrickleDown); + root.Add(_field); + _problem = new Label(); + _problem.AddToClassList("glyph-popup__problem"); + root.Add(_problem); + _button = new Button(Submit) { text = _submitText }; + root.Add(_button); + Validate(); + _field.schedule.Execute(() => _field.Focus()); + } + + private void OnKeyDown(KeyDownEvent keyEvent) + { + if (keyEvent.keyCode is KeyCode.Return or KeyCode.KeypadEnter) + { + Submit(); + keyEvent.StopPropagation(); + } + else if (keyEvent.keyCode == KeyCode.Escape) + { + editorWindow.Close(); + keyEvent.StopPropagation(); + } + } + + private void Validate() + { + string problem = _findProblem(_field.value); + _problem.text = problem ?? string.Empty; + _button.SetEnabled(problem == null); + } + + private void Submit() + { + string text = _field.value; + if (_findProblem(text) != null) + { + return; + } + editorWindow.Close(); + _submit(text); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs.meta b/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs.meta new file mode 100644 index 0000000..941d6d2 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphKeyPopup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 615128a7e7c058b376636e06fd7b7de1 diff --git a/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs b/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs new file mode 100644 index 0000000..5933d4c --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs @@ -0,0 +1,94 @@ +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// The pack's settings: how its enum is generated, the width proportional text gives a space, and the guides and + /// grid the glyph canvas draws. Every field is bound to the pack, so changes apply at once and undo. + /// + internal sealed class GlyphPackSettingsPopup : PopupWindowContent + { + private readonly WireframeGlyphPack _pack; + private readonly bool _isReadOnly; + + private GlyphPackSettingsPopup(WireframeGlyphPack pack, bool isReadOnly) + { + _pack = pack; + _isReadOnly = isReadOnly; + } + + internal static void Show(VisualElement anchor, WireframeGlyphPack pack, bool isReadOnly) + { + UnityEditor.PopupWindow.Show(anchor.worldBound, new GlyphPackSettingsPopup(pack, isReadOnly)); + } + + public override Vector2 GetWindowSize() + { + return new Vector2(340f, 236f); + } + + public override void OnGUI(Rect rect) + { + } + + public override void OnOpen() + { + VisualElement root = editorWindow.rootVisualElement; + if (GlyphAssets.StyleSheet != null) + { + root.styleSheets.Add(GlyphAssets.StyleSheet); + } + root.AddToClassList("glyph-popup"); + root.Add(new Label("Pack Settings") { name = "Title" }); + + string settings = WireframeGlyphPack.EnumSettingsField; + TextField enumName = AddField(root, new TextField("Enum Name"), $"{settings}.{GlyphEnumSettings.NameField}", + "Name of the generated enum. Empty uses the pack's name."); + TextField enumNamespace = AddField(root, new TextField("Enum Namespace"), + $"{settings}.{GlyphEnumSettings.NamespaceField}", + "Namespace of the generated enum. Empty uses the project's root namespace, if it has one."); + Label enumHint = new(); + enumHint.AddToClassList("glyph-popup__hint"); + root.Add(enumHint); + void ShowEnumHint() + { + string name = enumName.value.Length > 0 ? enumName.value : GlyphEnumGenerator.IdentifierOf(_pack.name); + string namespaceName = enumNamespace.value.Length > 0 + ? enumNamespace.value + : EditorSettings.projectGenerationRootNamespace; + enumHint.text = string.IsNullOrEmpty(namespaceName) ? $"Generates {name}" : $"Generates {namespaceName}.{name}"; + } + enumName.RegisterValueChangedCallback(_ => ShowEnumHint()); + enumNamespace.RegisterValueChangedCallback(_ => ShowEnumHint()); + + AddField(root, new FloatField("Space Width"), WireframeGlyphPack.SpaceWidthField, + "Width proportional text gives a character without lines, such as a space, in glyph boxes."); + AddField(root, new Slider("x-Height Guide", 0f, 1f) { showInputField = true }, WireframeGlyphPack.XHeightField, + "Height of the x-height guide the glyph canvas draws. Text layout doesn't use it."); + AddField(root, new Slider("Cap Height Guide", 0f, 1f) { showInputField = true }, + WireframeGlyphPack.CapHeightField, + "Height of the cap-height guide the glyph canvas draws. Text layout doesn't use it."); + AddField(root, + new SliderInt("Grid Divisions", WireframeGlyphPack.MinimumGridDivisions, + WireframeGlyphPack.MaximumGridDivisions) { showInputField = true }, + WireframeGlyphPack.GridDivisionsField, "Divisions of the canvas grid across the glyph box, which points snap to."); + + root.Bind(new SerializedObject(_pack)); + root.SetEnabled(!_isReadOnly); + root.schedule.Execute(ShowEnumHint); + } + + private static T AddField(VisualElement root, T field, string bindingPath, string tooltip) + where T : VisualElement, IBindable + { + field.bindingPath = bindingPath; + field.tooltip = tooltip; + field.AddToClassList("glyph-popup__field"); + root.Add(field); + return field; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs.meta b/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs.meta new file mode 100644 index 0000000..5552895 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPackSettingsPopup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bc62a3473cd369158cdc4fa7c294d5c4 diff --git a/UnityPackage/Editor/Glyphs/GlyphPreview.cs b/UnityPackage/Editor/Glyphs/GlyphPreview.cs new file mode 100644 index 0000000..47fd507 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPreview.cs @@ -0,0 +1,50 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// A glyph drawn in its box, with the box and the baseline faint behind it, in the element's text color, for the + /// gallery's tiles. + /// + internal sealed class GlyphPreview : VisualElement + { + private const float Margin = 4f; + + private GlyphStroke[] _strokes = Array.Empty(); + + internal GlyphPreview() + { + AddToClassList("glyph-preview"); + pickingMode = PickingMode.Ignore; + generateVisualContent += Draw; + } + + internal GlyphStroke[] Strokes + { + get => _strokes; + set + { + _strokes = value ?? Array.Empty(); + MarkDirtyRepaint(); + } + } + + private void Draw(MeshGenerationContext context) + { + Rect box = GlyphDrawing.FittedBox(contentRect, Margin); + if (box.width <= 0f) + { + return; + } + Painter2D painter = context.painter2D; + Color color = resolvedStyle.color; + GlyphDrawing.DrawRect(painter, box, GlyphDrawing.Faded(color, 0.15f), 1f); + float baseline = GlyphDrawing.ToBox(new Vector2(0f, WireframeGlyphPack.Baseline), box).y; + GlyphDrawing.DrawLine(painter, new Vector2(box.xMin, baseline), new Vector2(box.xMax, baseline), + GlyphDrawing.Faded(color, 0.15f), 1f); + GlyphDrawing.DrawStrokes(painter, _strokes, box, color, 1.5f); + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphPreview.cs.meta b/UnityPackage/Editor/Glyphs/GlyphPreview.cs.meta new file mode 100644 index 0000000..450bb14 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33d79986528c5ff6187fd4f4624db597 diff --git a/UnityPackage/Editor/Glyphs/GlyphTile.cs b/UnityPackage/Editor/Glyphs/GlyphTile.cs new file mode 100644 index 0000000..083d55e --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphTile.cs @@ -0,0 +1,58 @@ +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// A glyph in the gallery: its preview, its name below, and for a character its code. + internal sealed class GlyphTile : VisualElement + { + internal const string SelectedClass = "glyph-tile--selected"; + internal const string DraggedClass = "glyph-tile--dragged"; + + private readonly GlyphPreview _preview = new(); + private readonly Label _name = new(); + private readonly Label _code = new(); + + internal GlyphTile() + { + AddToClassList("glyph-tile"); + _name.AddToClassList("glyph-tile__name"); + _code.AddToClassList("glyph-tile__code"); + Add(_preview); + Add(_name); + Add(_code); + } + + internal GlyphReference Glyph { get; private set; } + + /// The text the gallery's search matches: the character and its code, or the keyword. + internal string SearchText { get; private set; } + + internal void Show(GlyphReference glyph, WireframeGlyphPack pack) + { + Glyph = glyph; + _preview.Strokes = glyph.StrokesIn(pack); + if (glyph.IsSymbol) + { + string keyword = pack.Symbols[glyph.Index].Keyword; + _name.text = keyword; + _code.style.display = DisplayStyle.None; + SearchText = keyword; + } + else + { + int character = pack.Characters[glyph.Index].Character; + _name.text = GlyphNames.CharacterOf(character); + _code.text = GlyphNames.CodeOf(character); + _code.style.display = DisplayStyle.Flex; + SearchText = $"{_name.text} {_code.text}"; + } + tooltip = _name.text; + } + + internal void SetSize(float size) + { + style.width = size; + _preview.style.height = size; + } + } +} diff --git a/UnityPackage/Editor/Glyphs/GlyphTile.cs.meta b/UnityPackage/Editor/Glyphs/GlyphTile.cs.meta new file mode 100644 index 0000000..5146ff7 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphTile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fb97dc8a9b0b4c38e7f039bd9dfa2c6f diff --git a/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs b/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs new file mode 100644 index 0000000..4a1d9df --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs @@ -0,0 +1,71 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using reromanlee.Wireframes.Editor; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Tests +{ + /// The Glyph Editor window, opened on a pack made for the test. + public class GlyphEditorWindowTests + { + private const string Folder = "Assets/WireframesGlyphEditorTest"; + + private WireframeGlyphPack _pack; + private GlyphEditorWindow _window; + + [SetUp] + public void CreatePack() + { + AssetDatabase.CreateFolder("Assets", "WireframesGlyphEditorTest"); + _pack = ScriptableObject.CreateInstance(); + GlyphStroke line = new(new[] { new Vector2(0.2f, 0.25f), new Vector2(0.8f, 0.25f) }, false); + _pack.SetGlyphs( + new[] { new CharacterGlyph('A', new[] { line }), new CharacterGlyph('B', null) }, + new[] { new SymbolGlyph("Heart", new[] { line }) }); + AssetDatabase.CreateAsset(_pack, $"{Folder}/Test Pack.asset"); + } + + [TearDown] + public void DeletePack() + { + if (_window != null) + { + _window.Close(); + } + Undo.ClearUndo(_pack); + AssetDatabase.DeleteAsset(Folder); + } + + [UnityTest] + public IEnumerator Window_ShowsEveryGlyphInItsSectionAndOpensOne() + { + _window = GlyphEditorWindow.Open(_pack); + yield return null; + + List tiles = _window.rootVisualElement.Query().ToList(); + Assert.That(tiles, Has.Count.EqualTo(3)); + Assert.That(tiles.FindAll(tile => tile.Glyph.IsSymbol), Has.Count.EqualTo(1)); + + _window.Open(new GlyphReference(true, 0)); + + Assert.That(_window.OpenGlyph, Is.EqualTo(new GlyphReference(true, 0))); + Assert.That(tiles.Find(tile => tile.Glyph.IsSymbol).ClassListContains(GlyphTile.SelectedClass), Is.True); + } + + [UnityTest] + public IEnumerator EditsMadeElsewhere_ShowUpInTheGallery() + { + _window = GlyphEditorWindow.Open(_pack); + yield return null; + + new GlyphPackEditing(_pack).AddCharacter('C'); + yield return new WaitForSecondsRealtime(0.3f); + + Assert.That(_window.rootVisualElement.Query().ToList(), Has.Count.EqualTo(4)); + } + } +} diff --git a/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs.meta b/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs.meta new file mode 100644 index 0000000..2937226 --- /dev/null +++ b/UnityPackage/Tests/Editor/GlyphEditorWindowTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 70fff401ebc6552f2695a30c627c6c82 From a5b05dc4bf93f616d8be5c1bba38ec0593f27cb6 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 4 Oct 2026 11:45:57 +0300 Subject: [PATCH 12/30] feature: glyph canvas --- UnityPackage/Editor/Glyphs/GlyphCanvas.cs | 577 ++++++++++++++++++ .../Editor/Glyphs/GlyphCanvas.cs.meta | 2 + UnityPackage/Editor/Glyphs/GlyphEditor.uss | 51 ++ UnityPackage/Editor/Glyphs/GlyphEditorPane.cs | 37 +- .../Editor/Glyphs/GlyphEditorWindow.cs | 10 + UnityPackage/Editor/Glyphs/GlyphGallery.cs | 46 +- .../Tests/Editor/GlyphEditorWindowTests.cs | 55 ++ 7 files changed, 766 insertions(+), 12 deletions(-) create mode 100644 UnityPackage/Editor/Glyphs/GlyphCanvas.cs create mode 100644 UnityPackage/Editor/Glyphs/GlyphCanvas.cs.meta diff --git a/UnityPackage/Editor/Glyphs/GlyphCanvas.cs b/UnityPackage/Editor/Glyphs/GlyphCanvas.cs new file mode 100644 index 0000000..eafc4e9 --- /dev/null +++ b/UnityPackage/Editor/Glyphs/GlyphCanvas.cs @@ -0,0 +1,577 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Edits a glyph's points with the mouse and keyboard, over the glyph box, its grid and the pack's guides. + /// + /// + /// Click a point to select it, and drag it to move it, snapped to the grid unless Shift is held. Ctrl+click + /// (Cmd+click on macOS) adds a point after the end of the selected stroke, or starts a stroke when none is selected. + /// Double-click a line to insert a point there. Delete removes the selected point, the arrow keys nudge it by a grid + /// step, the wheel zooms at the pointer, the middle button pans, and F frames the box. + /// + internal sealed class GlyphCanvas : VisualElement + { + private const float Margin = 28f; + private const float HitDistance = 7f; + private const float HandleSize = 7f; + private const float MinimumZoom = 0.25f; + private const float MaximumZoom = 12f; + + private static readonly float[] UnitValues = { 0f, 0.25f, 0.5f, 0.75f, 1f }; + + private readonly List