Skip to content

Commit ce372ef

Browse files
committed
feat(Studio): Highlight fuzzy-matched letters in auto-complete suggestions
1 parent c04bebe commit ce372ef

3 files changed

Lines changed: 111 additions & 79 deletions

File tree

Studio/CelesteStudio/Editing/PopupMenu.cs

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -108,24 +108,22 @@ public record Entry {
108108
/// Callback for when this entry is selected.
109109
public required Action OnUse;
110110
/// Whether the entry can be selected.
111-
public bool Disabled = false;
111+
public bool Disabled;
112112

113113
/// Index used to determine "usage frequency" of entry
114114
public int FrequentlyUsedIndex = -1;
115115

116116
/// Unique identifier for the category of the entry
117-
private string? storageKey;
118117
public string? StorageKey {
119-
get => storageKey;
120-
set => storageKey = value?.Replace('.', '#');
118+
get;
119+
init => field = value?.Replace('.', '#');
121120
}
122121

123122
/// Unique identifier inside the current category
124-
private string? storageName;
125123
[AllowNull]
126124
public string StorageName {
127-
get => storageName ?? DisplayText.Replace('.', '#');
128-
set => storageName = value?.Replace('.', '#');
125+
get => field ?? DisplayText.Replace('.', '#');
126+
init => field = value?.Replace('.', '#');
129127
}
130128

131129
/// Active data slot, used for storing persistant data
@@ -176,8 +174,7 @@ public ContentDrawable(PopupMenu menu) {
176174
public override void Draw(SKSurface surface) {
177175
var canvas = surface.Canvas;
178176

179-
var visibleElements = menu.VisibleEntries.ToArray();
180-
if (visibleElements.Length == 0) {
177+
if (menu.VisibleEntries.Length == 0) {
181178
return;
182179
}
183180

@@ -186,18 +183,17 @@ public override void Draw(SKSurface surface) {
186183
canvas.DrawRect(backgroundRect, Settings.Instance.Theme.PopupMenuBgPaint);
187184

188185
var font = FontManager.SKPopupFont;
189-
int maxDisplayLen = visibleElements.Select(entry => entry.DisplayText.Length).Aggregate(Math.Max);
186+
int maxDisplayLen = menu.VisibleEntries
187+
.Select(pair => pair.Entry.DisplayText.Length)
188+
.Aggregate(Math.Max);
190189

191190
float width = menu.ContentWidth - Settings.Instance.Theme.PopupMenuBorderPadding * 2.0f;
192191
float height = menu.EntryHeight;
193192
int iconWidth = menu.IconWidth;
194193

195-
const int rowCullOverhead = 3;
196-
int minRow = Math.Max(0, (int)(menu.ScrollPosition.Y / height) - rowCullOverhead);
197-
int maxRow = Math.Min(menu.shownEntries.Length - 1, (int)((menu.ScrollPosition.Y + menu.ClientSize.Height) / height) + rowCullOverhead);
198-
199-
for (int row = minRow; row <= maxRow; row++) {
200-
var entry = menu.shownEntries[row];
194+
for (int idx = 0; idx < menu.VisibleEntries.Length; idx++) {
195+
int row = menu.VisibleEntriesMinRow + idx;
196+
var (entry, indices) = menu.VisibleEntries[idx];
201197

202198
const float normalIconScale = 0.75f;
203199
const float hoverIconScale = 0.9f;
@@ -276,13 +272,52 @@ public override void Draw(SKSurface surface) {
276272
Settings.Instance.Theme.PopupMenuSelectedPaint);
277273
}
278274

279-
canvas.DrawText(entry.DisplayText,
280-
x: Settings.Instance.Theme.PopupMenuBorderPadding + Settings.Instance.Theme.PopupMenuEntryHorizontalPadding + menu.IconWidth,
281-
y: Settings.Instance.Theme.PopupMenuBorderPadding + row * height + Settings.Instance.Theme.PopupMenuEntryVerticalPadding + Settings.Instance.Theme.PopupMenuEntrySpacing / 2.0f + font.Offset(),
282-
font, entry.Disabled ? Settings.Instance.Theme.PopupMenuFgDisabledPaint : Settings.Instance.Theme.PopupMenuFgPaint);
275+
float textX = Settings.Instance.Theme.PopupMenuBorderPadding + Settings.Instance.Theme.PopupMenuEntryHorizontalPadding + menu.IconWidth;
276+
float textY = Settings.Instance.Theme.PopupMenuBorderPadding + row * height + Settings.Instance.Theme.PopupMenuEntryVerticalPadding + Settings.Instance.Theme.PopupMenuEntrySpacing / 2.0f + font.Offset();
277+
278+
// Highlight fuzzy-matched letter
279+
var boldFont = FontManager.SKPopupFontBold;
280+
var boldMetrics = boldFont.Metrics;
281+
float boldUnderlineOffset = boldMetrics.UnderlinePosition ?? boldFont.LineHeight() / 10.0f;
282+
283+
var strokePaint = new SKPaint();
284+
strokePaint.Style = SKPaintStyle.Stroke;
285+
strokePaint.Color = Settings.Instance.Theme.PopupMenuFgPaint.Color;
286+
strokePaint.StrokeWidth = boldMetrics.UnderlineThickness ?? 1.0f;
287+
strokePaint.StrokeCap = SKStrokeCap.Round;
288+
289+
int highlightIdx = 0;
290+
for (int currLetter = 0; currLetter < entry.DisplayText.Length;) {
291+
int nextLetter = highlightIdx < indices.Count ? indices[highlightIdx] : entry.DisplayText.Length;
292+
highlightIdx++;
293+
294+
var regularText = entry.DisplayText.AsSpan()[currLetter..nextLetter];
295+
canvas.DrawText(regularText,
296+
x: textX + currLetter * font.CharWidth(),
297+
y: textY,
298+
font, entry.Disabled ? Settings.Instance.Theme.PopupMenuFgDisabledPaint : Settings.Instance.Theme.PopupMenuFgPaint);
299+
300+
if (nextLetter == entry.DisplayText.Length) {
301+
break;
302+
}
303+
304+
float boldX = textX + nextLetter * font.CharWidth();
305+
306+
var boldText = entry.DisplayText.AsSpan()[nextLetter..(nextLetter + 1)];
307+
canvas.DrawText(boldText,
308+
x: boldX,
309+
y: textY,
310+
boldFont, entry.Disabled ? Settings.Instance.Theme.PopupMenuFgDisabledPaint : Settings.Instance.Theme.PopupMenuFgPaint);
311+
312+
float underlineY = textY + boldUnderlineOffset;
313+
canvas.DrawLine(boldX, underlineY, boldX + font.CharWidth(), underlineY, strokePaint);
314+
315+
currLetter = nextLetter + 1;
316+
}
317+
283318
canvas.DrawText(entry.ExtraText,
284-
x: Settings.Instance.Theme.PopupMenuBorderPadding + Settings.Instance.Theme.PopupMenuEntryHorizontalPadding + menu.IconWidth + font.CharWidth() * (maxDisplayLen + DisplayExtraPadding),
285-
y: Settings.Instance.Theme.PopupMenuBorderPadding + row * height + Settings.Instance.Theme.PopupMenuEntryVerticalPadding + Settings.Instance.Theme.PopupMenuEntrySpacing / 2.0f + font.Offset(),
319+
x: textX + (maxDisplayLen + DisplayExtraPadding) * font.CharWidth(),
320+
y: textY,
286321
font, Settings.Instance.Theme.PopupMenuFgExtraPaint);
287322
}
288323
}
@@ -460,14 +495,33 @@ public int RecommendedWidth {
460495
private Entry[] shownEntries = [];
461496
private readonly ContentDrawable drawable;
462497

463-
private int TopVisibleEntry => (int) MathF.Floor(ScrollPosition.Y / (float)EntryHeight);
464-
private int BottomVisibleEntry => (int) MathF.Ceiling((ScrollPosition.Y + ClientSize.Height) / (float)EntryHeight);
465-
private IEnumerable<Entry> VisibleEntries {
498+
private ((Entry Entry, List<int> FuzzyIndices)[]? Entries, int MinRow, int MaxRow) visibleEntryData;
499+
500+
private int VisibleEntriesMinRow => Math.Max(visibleEntryData.MinRow, 0);
501+
private int VisibleEntriesMaxRow => Math.Min(visibleEntryData.MaxRow, shownEntries.Length - 1);
502+
private (Entry Entry, List<int> FuzzyIndices)[] VisibleEntries {
466503
get {
467-
int top = TopVisibleEntry;
468-
int bottom = BottomVisibleEntry;
504+
const int rowCullOverhead = 3;
505+
float height = EntryHeight;
506+
int minRow = Math.Max(0, (int)(ScrollPosition.Y / height) - rowCullOverhead);
507+
int maxRow = Math.Min(shownEntries.Length - 1, (int)((ScrollPosition.Y + ClientSize.Height) / height) + rowCullOverhead);
508+
509+
if (visibleEntryData.Entries == null || visibleEntryData.MinRow != minRow || visibleEntryData.MaxRow != maxRow) {
510+
// Recalculate visible entries
511+
visibleEntryData.MinRow = minRow;
512+
visibleEntryData.MaxRow = maxRow;
513+
visibleEntryData.Entries = shownEntries
514+
.Skip(minRow)
515+
.Take(maxRow - minRow + 1)
516+
.Select(entry => {
517+
var indices = new List<int>();
518+
matcher.GetIndices(entry.SearchText.AsSpan(), filter.AsSpan(), indices);
519+
return (entry, indices);
520+
})
521+
.ToArray();
522+
}
469523

470-
return shownEntries.Skip(top).Take(Math.Min(bottom - top + 1, shownEntries.Length - top));
524+
return visibleEntryData.Entries;
471525
}
472526
}
473527

@@ -522,7 +576,7 @@ public void Recalc() {
522576
return 1;
523577
}
524578
if (lhsFavourite && rhsFavourite) {
525-
return lhsScore - rhsScore;
579+
return rhsScore - lhsScore;
526580
}
527581

528582
bool lhsFrequent = lhs.Entry.FrequentlyUsedIndex is >= 0 and < FrequentlyUsedCategorySize;
@@ -534,7 +588,7 @@ public void Recalc() {
534588
return 1;
535589
}
536590
if (lhsFrequent && rhsFrequent) {
537-
return (lhs.Entry.FrequentlyUsedIndex * 10 + lhsScore) - (rhs.Entry.FrequentlyUsedIndex * 10 + rhsScore);
591+
return (rhsScore - lhsScore) + (rhs.Entry.FrequentlyUsedIndex - lhs.Entry.FrequentlyUsedIndex) * 5;
538592
}
539593

540594
bool lhsSuggestion = lhs.Entry.Suggestion;
@@ -546,10 +600,10 @@ public void Recalc() {
546600
return 1;
547601
}
548602
if (lhsSuggestion && rhsSuggestion) {
549-
return lhsScore - rhsScore;
603+
return rhsScore - lhsScore;
550604
}
551605

552-
return lhsScore - rhsScore;
606+
return rhsScore - lhsScore;
553607
}))
554608
.Select(pair => pair.Entry)
555609
.ToArray();
@@ -564,6 +618,9 @@ public void Recalc() {
564618
}];
565619
}
566620

621+
// Clear cached entries
622+
visibleEntryData.Entries = null;
623+
567624
selectedEntry = Math.Clamp(selectedEntry, 0, shownEntries.Length - 1);
568625

569626
// Calculate content bounds. Calculate height first to account for scroll bar

Studio/CelesteStudio/FontManager.cs

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using Eto;
21
using System.Collections.Generic;
32
using System.Linq;
43
using System.Reflection;
@@ -16,7 +15,7 @@ public static class FontManager {
1615
public const string FontFamilyBuiltinDisplayName = "JetBrains Mono (builtin)";
1716

1817
private static Font? editorFont, statusFont;
19-
private static SKFont? skEditorFontRegular, skEditorFontBold, skEditorFontItalic, skEditorFontBoldItalic, skStatusFont, skPopupFont;
18+
private static SKFont? skEditorFontRegular, skEditorFontBold, skEditorFontItalic, skEditorFontBoldItalic, skStatusFont, skPopupFont, skPopupFontBold;
2019

2120
public static Font EditorFont => editorFont ??= CreateFont(Settings.Instance.FontFamily, Settings.Instance.EditorFontSize);
2221
public static Font StatusFont => statusFont ??= CreateFont(Settings.Instance.FontFamily, Settings.Instance.StatusFontSize);
@@ -27,14 +26,15 @@ public static class FontManager {
2726
public static SKFont SKEditorFontBoldItalic => skEditorFontBoldItalic ??= CreateSKFont(Settings.Instance.FontFamily, Settings.Instance.EditorFontSize * Settings.Instance.FontZoom, FontStyle.Bold | FontStyle.Italic);
2827
public static SKFont SKStatusFont => skStatusFont ??= CreateSKFont(Settings.Instance.FontFamily, Settings.Instance.StatusFontSize);
2928
public static SKFont SKPopupFont => skPopupFont ??= CreateSKFont(Settings.Instance.FontFamily, Settings.Instance.PopupFontSize);
29+
public static SKFont SKPopupFontBold => skPopupFontBold ??= CreateSKFont(Settings.Instance.FontFamily, Settings.Instance.PopupFontSize, FontStyle.Bold);
3030

3131
private static FontFamily? builtinFontFamily;
3232
public static Font CreateFont(string fontFamily, float size, FontStyle style = FontStyle.None) {
3333
if (fontFamily == FontFamilyBuiltin) {
3434
var asm = Assembly.GetExecutingAssembly();
3535
builtinFontFamily ??= FontFamily.FromStreams(asm.GetManifestResourceNames()
3636
.Where(name => name.StartsWith("JetBrainsMono/"))
37-
.Select(name => asm.GetManifestResourceStream(name)));
37+
.Select(asm.GetManifestResourceStream));
3838

3939
return new Font(builtinFontFamily, size, style);
4040
} else {
@@ -67,32 +67,6 @@ public static SKFont CreateSKFont(string fontFamily, float size, FontStyle style
6767
}
6868
}
6969

70-
private static readonly Dictionary<Font, float> charWidthCache = new();
71-
public static float CharWidth(this Font font) {
72-
if (charWidthCache.TryGetValue(font, out float width)) {
73-
return width;
74-
}
75-
76-
width = font.MeasureString("X").Width;
77-
charWidthCache.Add(font, width);
78-
return width;
79-
}
80-
public static float LineHeight(this Font font) {
81-
if (Platform.Instance.IsWpf) {
82-
// WPF reports the line height a bit to small for some reason?
83-
return font.LineHeight + 5.0f;
84-
}
85-
86-
return font.LineHeight;
87-
}
88-
public static float MeasureWidth(this Font font, string text, bool measureReal = false) {
89-
if (measureReal) {
90-
return string.IsNullOrEmpty(text) ? 0.0f : font.MeasureString(text).Width;
91-
}
92-
93-
return font.CharWidth() * text.Length;
94-
}
95-
9670
private static readonly Dictionary<SKFont, float> widthCache = [];
9771
public static float CharWidth(this SKFont font) {
9872
if (widthCache.TryGetValue(font, out float width)) {
@@ -122,7 +96,6 @@ public static void OnFontChanged() {
12296
// Clear cached fonts
12397
editorFont?.Dispose();
12498
statusFont?.Dispose();
125-
charWidthCache.Clear();
12699

127100
editorFont = statusFont = null;
128101

@@ -132,12 +105,9 @@ public static void OnFontChanged() {
132105
skEditorFontBoldItalic?.Dispose();
133106
skStatusFont?.Dispose();
134107
skPopupFont?.Dispose();
108+
skPopupFontBold?.Dispose();
135109
widthCache.Clear();
136110

137-
skEditorFontRegular = skEditorFontBold = skEditorFontItalic = skEditorFontBoldItalic = skStatusFont = skPopupFont = null;
111+
skEditorFontRegular = skEditorFontBold = skEditorFontItalic = skEditorFontBoldItalic = skStatusFont = skPopupFont = skPopupFontBold = null;
138112
}
139-
140-
private static Font CreateEditor(FontStyle style) => CreateFont(Settings.Instance.FontFamily, Settings.Instance.EditorFontSize * Settings.Instance.FontZoom, style);
141-
private static Font CreateStatus() => CreateFont(Settings.Instance.FontFamily, Settings.Instance.StatusFontSize);
142-
private static Font CreatePopup() => CreateFont(Settings.Instance.FontFamily, Settings.Instance.PopupFontSize);
143113
}

Studio/CelesteStudio/Util/FuzzyMatcher.cs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System;
33
using System.Collections.Generic;
44
using System.Diagnostics;
5+
using System.Linq;
56
using System.Runtime.CompilerServices;
67
using System.Runtime.InteropServices;
78

@@ -123,22 +124,24 @@ public int Populate(FuzzyMatcher matcher, ReadOnlySpan<char> needle, bool indice
123124
}
124125

125126
public void ReconstructOptimalPath(ushort maxScoreEnd, int matrixLen, int start, List<int> indicesList) {
126-
indicesList.EnsureCapacity(indicesList.Count + RowOffset.Length);
127+
indicesList.EnsureCapacity(RowOffset.Length);
128+
indicesList.AddRange(Enumerable.Repeat(0, RowOffset.Length));
129+
var indices = CollectionsMarshal.AsSpan(indicesList);
127130

128131
var rowOffsetSpan = RowOffset.Span;
129132
var scoreCellsSpan = ScoreCells.Span;
130133

131-
var indices = CollectionsMarshal.AsSpan(indicesList)[indicesList.Count..];
132134
int lastRowOffset = rowOffsetSpan[^1];
133135
indices[RowOffset.Length - 1] = start + maxScoreEnd + lastRowOffset;
134136

135137
var matrixCells = MatrixCells.Span[..matrixLen];
136138
int width = ScoreCells.Length;
137139

138-
int rowIdx = 0;
139-
int rowOffset = rowOffsetSpan[0];
140+
int rowIdx = RowOffset.Length - 2;
141+
int rowOffset = rowOffsetSpan[rowIdx];
142+
int relativeRowOffset = rowOffset - rowIdx;
140143

141-
int splitIdx = matrixCells.Length - (width - rowOffset);
144+
int splitIdx = matrixCells.Length - (width - relativeRowOffset);
142145
var row = matrixCells[splitIdx..];
143146
matrixCells = matrixCells[..splitIdx];
144147

@@ -154,19 +157,21 @@ public void ReconstructOptimalPath(ushort maxScoreEnd, int matrixLen, int start,
154157
}
155158

156159
byte mask = matched ? MATRIX_M_MATCH_MASK : MATRIX_P_MATCH_MASK;
157-
bool nextMatched = (row[col] & mask) == 0;
160+
bool nextMatched = (row[col] & mask) != 0;
158161

159162
if (matched) {
160-
rowIdx++;
161-
if (rowIdx >= RowOffset.Length) {
163+
if (rowIdx == 0) {
162164
break;
163165
}
164166

167+
rowIdx--;
165168
int nextRowOffset = rowOffsetSpan[rowIdx];
166-
int nextSplitIdx = matrixCells.Length - (width - nextRowOffset - rowIdx);
169+
int nextRelativeRowOffset = nextRowOffset - rowIdx;
170+
int nextSplitIdx = matrixCells.Length - (width - nextRelativeRowOffset);
167171
var nextRow = matrixCells[nextSplitIdx..];
168172
matrixCells = matrixCells[..nextSplitIdx];
169173

174+
col += rowOffset - nextRowOffset;
170175
rowOffset = nextRowOffset;
171176
row = nextRow;
172177
}
@@ -548,7 +553,7 @@ private ushort MatchGreedy(ReadOnlySpan<char> needle, ReadOnlySpan<char> haystac
548553
}
549554

550555
private (int Start, int GreedyEnd, int End)? Prefilter(ReadOnlySpan<char> needle, ReadOnlySpan<char> haystack, bool onlyGreedy) {
551-
int start = haystack[..^needle.Length].IndexOfChar(needle[0], ignoreCase: IgnoreCase);
556+
int start = haystack[..(haystack.Length - needle.Length + 1)].IndexOfChar(needle[0], ignoreCase: IgnoreCase);
552557
if (start == -1) {
553558
return null;
554559
}
@@ -590,8 +595,8 @@ private ushort CalculateScore(ReadOnlySpan<char> needle, ReadOnlySpan<char> hays
590595

591596
prevClass = currClass;
592597

593-
char needleChar = GetCharNormalized(needle[0]);
594-
int needleIdx = 1;
598+
char needleChar = GetCharNormalized(needle.Length > 1 ? needle[1] : needle[0]);
599+
int needleIdx = 2;
595600

596601
for (int idx = start + 1; idx < end; idx++) {
597602
(char currChar, currClass) = GetCharClassNormalized(haystack[idx]);

0 commit comments

Comments
 (0)