Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UserInterface/TextLayout: Add word splitting for rich text #2352

Closed
wants to merge 12 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 149 additions & 52 deletions Robust.Client/UserInterface/TextLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ public static class TextLayout
/// <param name="y">The offset from the base position's y coordinate to render this chunk of text.</param>
/// <param name="w">The width the word (i.e. the sum of all its <c>Advance</c>'s).</param>
/// <param name="h">The height of the tallest character's <c>BearingY</c>.</param>
/// <param name="ln">The line number that the word is assigned to.</param>
/// <param name="spw">The width allocated to this word.</param>
/// <param name="wt">The detected word type.</param>
/// <param name="rw">The width of each rune.</param>
public record struct Offset
{
public int section;
Expand All @@ -54,8 +56,25 @@ public record struct Offset
public int y;
public int h;
public int w;
public int ln;
public int spw;
public WordType wt;
public int[] rw;

public string ToArrows()
{
var sb = new StringBuilder()
.Append(' ', charOffs)
.Append('^');

if (length > 1)
sb.Append('-', length - 2);

if (length > 0)
sb.Append('^');

return sb.ToString();
}
}

public enum WordType : byte
Expand All @@ -65,23 +84,31 @@ public enum WordType : byte
LineBreak,
}

public static WordType Classify(Rune r)
{
if (r == (Rune) '\n')
return WordType.LineBreak;
else if (Rune.IsSeparator(r))
return WordType.Space;

return WordType.Normal;
}

public static ImmutableArray<Offset> 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),
Split(text, fonts, scale, fclass, options),
w,
fonts,
scale,
lineSpacing, wordSpacing,
lineSpacing,
fclass,
options
);
Expand All @@ -97,16 +124,19 @@ public static ImmutableArray<Offset> Layout(
// 6. That space has (fp*fs) pixels.
public static ImmutableArray<Offset> Layout(
ISectionable src,
ImmutableArray<Offset> text,
List<Offset> text,
int w,
IFontLibrary fonts,
float scale = 1.0f,
int lineSpacing = 0,
int wordSpacing = 0,
FontClass? fclass = default,
LayoutOptions options = LayoutOptions.Default
)
{
// how about no
if (w == 0)
return ImmutableArray<Offset>.Empty;

var lw = new WorkQueue<(
List<Offset> wds,
List<int> gaps,
Expand All @@ -115,33 +145,97 @@ public static ImmutableArray<Offset> Layout(
int maxPri,
int tPri,
int lnh
)>(postcreate: i => i with
{
wds = new List<Offset>(),
gaps = new List<int>()
});
)>(
blank: () => new () { lnrem = w, maxPri = 1, wds = new List<Offset>(), gaps = new List<int>() }
);

var lastAlign = TextAlign.Left;

// Since we edit this one, we need to make a copy.
var wdq = text.ShallowClone();

// forced newline, disables skipping leading spaces
var fnl = false;

// Calculate line boundaries
foreach (var wd in text)
for (var i = 0; i < wdq.Count; i++)
{
var hz = src[wd.section].Alignment.Horizontal();
restart:
var wd = wdq[i];
var sec = src[wd.section];
var hz = sec.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 (!fnl && wd.wt == WordType.Space && lw.Work.wds.Count == 0)
continue;

if (lw.Work.lnrem < wd.w || wd.wt == WordType.LineBreak)
fnl=false;

if (wd.wt == WordType.LineBreak)
{
lw.Flush();
lw.Work.lnrem = w;
lw.Work.maxPri = 1;
fnl=true;
}
else if (lw.Work.lnrem < wd.w)
{
// We won't split if we are asked not to, or if the word can fit on one line.
if (!options.HasFlag(LayoutOptions.NoWordSplit) && wd.w > w)
{
var sbo = 0; // section byte offset
var j = 0; // just a rune counter (to index wd.rw)
var swdw = 0; // sub-word width

foreach (var r in src[wd.section]
.Content.Substring(wd.charOffs, wd.length)
.EnumerateRunes())
{
if (swdw + wd.rw[j] > lw.Work.lnrem && j > 0)
{
// the half that stays on the current line
var left = wd with {
length=sbo,
w=swdw,
rw=wd.rw[0..j]
};

// the half that gets moved down
var right = wd with {
charOffs=wd.charOffs+left.length,
length=wd.length-left.length,
w=wd.w-left.w,
rw=wd.rw[(j-1)..^1],
};

// replace this word with the first half of itself
wdq[i] = left;

// and add the new half to the queue
wdq.Insert(i+1, right);

// reprocess from the start
goto restart;
}

// Advance our various counters
sbo += r.Utf16SequenceLength;
swdw += wd.rw[j];
j++;
}
}
else
{
lw.Flush();
if (wd.wt == WordType.Space)
continue;
}
}

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);
lw.Work.sptot += wd.spw;
lw.Work.lnrem -= wd.w + wd.spw;
lw.Work.wds.Add(wd);
Expand All @@ -150,7 +244,8 @@ int lnh

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 lnnum = 0;
foreach (var (ln, gaps, lnrem, sptot, maxPri, tPri, lnh) in lw.Done)
{
int px=0, maxlh=0;

Expand Down Expand Up @@ -181,7 +276,8 @@ int lnh
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,
}
},
ln = lnnum,
};

if (i < spDist.Length)
Expand All @@ -193,6 +289,8 @@ int lnh
prevDesc = desc;
}
py += options.HasFlag(LayoutOptions.UseRenderTop) ? lnh : (lineSpacing + maxlh);

lnnum++;
}

return lw.Done.SelectMany(e => e.wds).ToImmutableArray();
Expand Down Expand Up @@ -253,12 +351,10 @@ private static (int gapPri, int adv) TransitionWeights (TextAlign l, TextAlign r
// 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<Offset> Split(
public static List<Offset> Split(
ISectionable text,
IFontLibrary fonts,
float scale,
int wordSpacing,
int runeSpacing,
FontClass? fclass,
LayoutOptions options = LayoutOptions.Default
)
Expand All @@ -268,74 +364,72 @@ public static ImmutableArray<Offset> Split(
var s=0;
var lsbo=0;
var sbo=0;
var runew = new int[0];
var wq = new WorkQueue<Offset>(
w =>
conv: w =>
{
var len = sbo-lsbo;
lsbo = sbo;
return w with { length=len };
var o = w with { length=len, rw=runew[w.charOffs..(w.charOffs+len)] };
return o;
},
default,
default,
w => w with { section=s, charOffs=sbo }
blank: default,
check: w => w.wt != WordType.Normal || sbo > lsbo,
postcreate: 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");
throw new Exception("Section with unknown or unimplemented Meta flag");

runew = new int[sec.Content.EnumerateRunes().Count()];
lsbo = 0;
sbo = 0;
var fnt = flib.Update(sec.Style, sec.Size);
wq.Reset();

var runec=0;
foreach (var r in sec.Content.EnumerateRunes())
{
if (r == (Rune) '\n')
WordType cr = Classify(r);
if (wq.Work.wt != cr || cr == WordType.LineBreak)
{
wq.Flush();
wq.Work.wt = WordType.LineBreak;
wq.Work.wt = cr;
if (cr == WordType.LineBreak)
wq.Flush();
}
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)
{
runec++;
sbo += r.Utf16SequenceLength;
continue;
}
else if (fnt is DummyFont)
cm = new CharMetrics();
else
throw new Exception("unable to get character metrics");
}

wq.Work.h = Math.Max(wq.Work.h, cm.Value.Height);
runew[runec] = cm.Value.Advance;
wq.Work.w += cm.Value.Advance;
if (wq.Work.wt == WordType.Normal)
wq.Work.spw = runeSpacing;
sbo += r.Utf16SequenceLength;
runec++;
}

wq.Flush(true);
}


return wq.Done.ToImmutableArray();
return wq.Done;
}

[Flags]
Expand All @@ -348,6 +442,9 @@ public enum LayoutOptions : byte

// NoFallback disables the use of the Fallback character.
NoFallback = 0b0000_0010,

// NoWordSplit disable splitting words that run over the line boundary.
NoWordSplit = 0b0000_0100,
}

// WorkQueue is probably a misnomer. All it does is streamline a pattern I ended up using
Expand Down