using System; using System.Linq; using System.Collections.Generic; using System.Collections.Immutable; using System.Text; using Robust.Client.Graphics; using Robust.Shared.Utility; namespace Robust.Client.UserInterface { public static class TextLayout { /// /// An Offset is a simplified instruction for rendering a text block. /// /// /// /// Pseudocode for rendering: /// /// (int x, int y) topLeft = (10, 20); /// var libn = style.FontLib.StartFont(defaultFontID, defaultFontStyle, defaultFontSize); /// foreach (var r in returnedWords) /// { /// var section = Message.Sections[section]; /// var font = libn.Update(section.Style, section.Size); /// font.DrawAt( /// text=section.Content.Substring(charOffs, length), /// x=topLeft.x + r.x, /// y=topLeft.y + r.y, /// color=new Color(section.Color) /// ) /// } /// /// /// /// /// The index of the backing store (usually a ) in the /// container (usually a ) to which the Offset belongs. /// /// The byte offset in to the to render. /// The number of bytes after to render. /// The offset from the base position's x coordinate to render this chunk of text. /// The offset from the base position's y coordinate to render this chunk of text. /// The width the word (i.e. the sum of all its Advance's). /// The height of the tallest character's BearingY. /// The width allocated to this word. /// The detected word type. public record struct Offset { public int section; public int charOffs; public int length; public int x; public int y; public int h; public int w; public int spw; public WordType wt; } public enum WordType : byte { Normal, Space, LineBreak, } public static ImmutableArray Layout( ISectionable text, int w, IFontLibrary fonts, float scale = 1.0f, int lineSpacing = 0, int wordSpacing = 0, int runeSpacing = 0, FontClass? fclass = default, LayoutOptions options = LayoutOptions.Default ) => Layout( text, Split(text, fonts, scale, wordSpacing, runeSpacing, fclass, options), w, fonts, scale, lineSpacing, wordSpacing, fclass, options ); // Actually produce the layout data. // The algorithm is basically ripped from CSS Flexbox. // // 1. Add up all the space each word takes // 2. Subtract that from the line width (w) // 3. Save that as the free space (fs) // 4. Add up each gap's priority value (Σpri) // 5. Assign each gap a final priority (fp) of ((priMax - pri) / Σpri) // 6. That space has (fp*fs) pixels. public static ImmutableArray Layout( ISectionable src, ImmutableArray text, int w, IFontLibrary fonts, float scale = 1.0f, int lineSpacing = 0, int wordSpacing = 0, FontClass? fclass = default, LayoutOptions options = LayoutOptions.Default ) { var lw = new WorkQueue<( List wds, List gaps, int lnrem, int sptot, int maxPri, int tPri, int lnh )>(postcreate: i => i with { wds = new List(), gaps = new List() }); var lastAlign = TextAlign.Left; // Calculate line boundaries foreach (var wd in text) { var hz = src[wd.section].Alignment.Horizontal(); (int gW, int adv) = TransitionWeights(lastAlign, hz); lastAlign = hz; lw.Work.gaps.Add(gW+lw.Work.maxPri); lw.Work.tPri += gW+lw.Work.maxPri; lw.Work.maxPri += adv; lw.Work.lnh = Math.Max(lw.Work.lnh, wd.h); if (lw.Work.lnrem < wd.w || wd.wt == WordType.LineBreak) { lw.Flush(); lw.Work.lnrem = w; lw.Work.maxPri = 1; } lw.Work.sptot += wd.spw; lw.Work.lnrem -= wd.w + wd.spw; lw.Work.wds.Add(wd); } lw.Flush(true); var flib = fonts.StartFont(fclass); int py = flib.Current.GetAscent(scale); foreach ((var ln, var gaps, var lnrem, var sptot, var maxPri, var tPri, var lnh) in lw.Done) { int px=0, maxlh=0; var spDist = new int[gaps.Count]; for (int i = 0; i < gaps.Count; i++) spDist[i] = (int) (((float) gaps[i] / (float) tPri) * (float) sptot); int prevAsc=0, prevDesc=0; for (int i = 0; i < ln.Count; i++) { var ss = src[ln[i].section]; var sf = flib.Update(ss.Style, ss.Size); var asc = sf.GetAscent(scale); var desc = sf.GetDescent(scale); maxlh = Math.Max(maxlh, sf.GetAscent(scale)); if (i - 1 > 0 && i - 1 < spDist.Length) { px += spDist[i - 1] / 2; } ln[i] = ln[i] with { x = px, y = py + ss.Alignment.Vertical() switch { TextAlign.Baseline => 0, TextAlign.Bottom => -(desc - prevDesc), // Scoot it up by the descent TextAlign.Top => (asc - prevAsc), TextAlign.Subscript => -ln[i].h / 8, // Technically these should be derived from the font data, TextAlign.Superscript => ln[i].h / 4, // but I'm not gonna bother figuring out how to pull it from them. _ => 0, } }; if (i < spDist.Length) { px += spDist[i] / 2 + ln[i].w; } prevAsc = asc; prevDesc = desc; } py += options.HasFlag(LayoutOptions.UseRenderTop) ? lnh : (lineSpacing + maxlh); } return lw.Done.SelectMany(e => e.wds).ToImmutableArray(); } private static (int gapPri, int adv) TransitionWeights (TextAlign l, TextAlign r) { l = l.Horizontal(); r = r.Horizontal(); // Technically these could be slimmed down, but it's as much to help explain the system // as it is to implement it. // p (aka gapPri) is how high up the food chain each gap should be. // _LOWER_ p means more (since we do first-come first-serve). // a (aka adv) is how much we increment the gapPri counter, meaning how much less important // future alignment changes are. // Left alignment. (int p, int a) la = (l, r) switch { ( TextAlign.Left, TextAlign.Left) => (0, 0), // Left alignment doesn't care about inter-word spacing ( _, TextAlign.Left) => (0, 0), // or anything that comes before it, ( TextAlign.Left, _) => (1, 1), // only what comes after it. ( _, _) => (0, 0) }; // Right alignment (int p, int a) ra = (l, r) switch { ( TextAlign.Right, TextAlign.Right) => (0, 0), // Right alignment also does not care about inter-word spacing, ( _, TextAlign.Right) => (1, 1), // but it does care what comes before it, ( TextAlign.Right, _) => (0, 0), // but not after. ( _, _) => (0, 0) }; // Centering (int p, int a) ca = (l, r) switch { ( TextAlign.Center, TextAlign.Center) => (0, 0), // Centering still doesn't care about inter-word spacing, ( _, TextAlign.Center) => (1, 0), // but it cares about both what comes before it, ( TextAlign.Center, _) => (1, 1), // and what comes after it. ( _, _) => (0, 0) }; // Justifying (int p, int a) ja = (l, r) switch { (TextAlign.Justify, TextAlign.Justify) => (1, 0), // Justification cares about inter-word spacing. ( _, TextAlign.Justify) => (0, 1), // And (sort of) what comes before it. ( _, _) => (0, 0) }; return new ( la.p + ra.p + ca.p + ja.p, la.a + ra.a + ca.a + ja.a ); } // Split creates a list of words broken based on their boundaries. // Users are encouraged to reuse this for as long as it accurately reflects // the content they're trying to display. public static ImmutableArray Split( ISectionable text, IFontLibrary fonts, float scale, int wordSpacing, int runeSpacing, FontClass? fclass, LayoutOptions options = LayoutOptions.Default ) { var nofb = options.HasFlag(LayoutOptions.NoFallback); var s=0; var lsbo=0; var sbo=0; var wq = new WorkQueue( w => { var len = sbo-lsbo; lsbo = sbo; return w with { length=len }; }, default, default, w => w with { section=s, charOffs=sbo } ); var flib = fonts.StartFont(fclass); for (s = 0; s < text.Length; s++) { var sec = text[s]; #warning Meta.Localized not yet implemented if (sec.Meta != default) throw new Exception("Text section with unknown or unimplemented Meta flag"); lsbo = 0; sbo = 0; var fnt = flib.Update(sec.Style, sec.Size); wq.Reset(); foreach (var r in sec.Content.EnumerateRunes()) { if (r == (Rune) '\n') { wq.Flush(); wq.Work.wt = WordType.LineBreak; } else if (Rune.IsSeparator(r)) { if (wq.Work.wt != WordType.Space) { wq.Work.w += wordSpacing; wq.Flush(); wq.Work.wt = WordType.Space; } } else if (wq.Work.wt != WordType.Normal) wq.Flush(); sbo += r.Utf16SequenceLength; var cm = fnt.GetCharMetrics(r, scale, !nofb); if (!cm.HasValue) { if (nofb) continue; else if (fnt is DummyFont) cm = new CharMetrics(); else throw new Exception("unable to get character metrics"); } // This may be less-than-optimal, since we're ignoring anything below the origin. wq.Work.h = Math.Max(wq.Work.h, cm.Value.BearingY); wq.Work.w += cm.Value.Advance; if (wq.Work.wt == WordType.Normal) wq.Work.spw = runeSpacing; } wq.Flush(true); } return wq.Done.ToImmutableArray(); } [Flags] public enum LayoutOptions : byte { Default = 0b0000_0000, // Measure the actual height of runes to space lines. UseRenderTop = 0b0000_0001, // NoFallback disables the use of the Fallback character. NoFallback = 0b0000_0010, } // WorkQueue is probably a misnomer. All it does is streamline a pattern I ended up using // repeatedly where I'd have a list of something and a WIP, then I'd flush the WIP in to // the list. private class WorkQueue where TIn : new() { // _blank creates a new T if _refresh says it needs to. private Func _blank = () => new TIn(); private Func? _postcr; private Func _check = _ => true; private Func _conv; public List Done = new(); public TIn Work; public WorkQueue( Func conv, Func? blank = default, Func? check = default, Func? postcreate = default ) { _conv = conv; if (blank is not null) _blank = blank; if (check is not null) _check = check; if (postcreate is not null) _postcr = postcreate; Work = _blank.Invoke(); if (_postcr is not null) Work = _postcr.Invoke(Work); } public void Reset() { Work = _blank.Invoke(); if (_postcr is not null) Work = _postcr.Invoke(Work); } public void Flush(bool force = false) { if (_check.Invoke(Work) || force) { Done.Add(_conv(Work)); Work = _blank.Invoke(); if (_postcr is not null) Work = _postcr.Invoke(Work); } } } private class WorkQueue : WorkQueue where T : new() { private static Func __conv = i => i; public WorkQueue( Func? conv = default, Func? blank = default, Func? check = default, Func? postcreate = default ) : base(conv ?? __conv, blank, check, postcreate) { } } } }