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)
{
}
}
}
}