Skip to content

Commit b0216fc

Browse files
committed
Improve log viewer filters
1 parent 21f6e57 commit b0216fc

8 files changed

Lines changed: 267 additions & 40 deletions

BetterModMenu.Tests/BetterModMenu.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<Compile Include="..\Data\FileNameCollisionRules.cs" Link="Data\FileNameCollisionRules.cs" />
2020
<Compile Include="..\Data\ManifestScanner.cs" Link="Data\ManifestScanner.cs" />
2121
<Compile Include="..\Data\LogFolderOpenRules.cs" Link="Data\LogFolderOpenRules.cs" />
22+
<Compile Include="..\Data\LogLevelFilterService.cs" Link="Data\LogLevelFilterService.cs" />
2223
<Compile Include="..\Data\LogViewerService.cs" Link="Data\LogViewerService.cs" />
2324
<Compile Include="..\Data\LogHighlightService.cs" Link="Data\LogHighlightService.cs" />
2425
<Compile Include="..\Data\ModInstallPathResolver.cs" Link="Data\ModInstallPathResolver.cs" />

BetterModMenu.Tests/LogicTests.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,7 @@ public void BuildBody_DescribesV16ActionsWithoutRuntimeSpecificInstructions()
728728
StringAssert.Contains(body, "Backup");
729729
StringAssert.Contains(body, "CSV");
730730
StringAssert.Contains(body, "Logs");
731+
StringAssert.Contains(body, "level toggles");
731732
StringAssert.Contains(body, "Load lets you choose");
732733
Assert.IsFalse(body.Contains("timestamped safety", StringComparison.OrdinalIgnoreCase));
733734
StringAssert.Contains(body, "cloud behavior stays opt-in");
@@ -837,6 +838,49 @@ public void BuildHighlightedBbCode_HighlightsGenericWarningsAndErrors()
837838
StringAssert.Contains(highlighted, "[color=ff4040][b]System.Exception: load failed[/b][/color]");
838839
}
839840

841+
[TestMethod]
842+
public void Classify_RecognizesCommonLogLevelVariants()
843+
{
844+
Assert.AreEqual(LogLevelFilter.Debug, LogLevelFilterService.Classify("[DEBUG] Debug message"));
845+
Assert.AreEqual(LogLevelFilter.Debug, LogLevelFilterService.Classify("[lb]DEBUG[rb] Escaped debug message"));
846+
Assert.AreEqual(LogLevelFilter.Info, LogLevelFilterService.Classify("[Server thread/INFO] Informational message"));
847+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("[WARN] Warning message"));
848+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("[Server thread/WARN] Warning message"));
849+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("WARN: warning message"));
850+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("WARN: operation failed"));
851+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("WARNING: warning message"));
852+
Assert.AreEqual(LogLevelFilter.Warning, LogLevelFilterService.Classify("WARNING: Running Modded. Loaded 19 mods WITH ERRORS!"));
853+
Assert.AreEqual(LogLevelFilter.Error, LogLevelFilterService.Classify("[ERROR] Error message"));
854+
Assert.AreEqual(LogLevelFilter.Error, LogLevelFilterService.Classify("[Server thread/ERROR] Error message"));
855+
Assert.AreEqual(LogLevelFilter.Error, LogLevelFilterService.Classify("[ERR] Error message"));
856+
Assert.AreEqual(LogLevelFilter.Error, LogLevelFilterService.Classify("System.Exception: load failed"));
857+
Assert.AreEqual(LogLevelFilter.Other, LogLevelFilterService.Classify("plain continuation line"));
858+
}
859+
860+
[TestMethod]
861+
public void Filter_CanShowOnlyOneLevelOrExcludeAnyLevel()
862+
{
863+
string content = string.Join('\n',
864+
"[DEBUG] debug line",
865+
"[INFO] info line",
866+
"[WARN] warning line",
867+
"[ERROR] error line",
868+
"plain continuation");
869+
870+
Assert.AreEqual("[DEBUG] debug line", LogLevelFilterService.Filter(content, LogLevelFilter.Debug));
871+
Assert.AreEqual("[INFO] info line", LogLevelFilterService.Filter(content, LogLevelFilter.Info));
872+
Assert.AreEqual("[WARN] warning line", LogLevelFilterService.Filter(content, LogLevelFilter.Warning));
873+
Assert.AreEqual("[ERROR] error line", LogLevelFilterService.Filter(content, LogLevelFilter.Error));
874+
875+
string withoutDebug = LogLevelFilterService.Filter(content, LogLevelFilter.All & ~LogLevelFilter.Debug);
876+
877+
Assert.IsFalse(withoutDebug.Contains("debug line", StringComparison.Ordinal));
878+
StringAssert.Contains(withoutDebug, "info line");
879+
StringAssert.Contains(withoutDebug, "warning line");
880+
StringAssert.Contains(withoutDebug, "error line");
881+
StringAssert.Contains(withoutDebug, "plain continuation");
882+
}
883+
840884
[TestMethod]
841885
public void TryReadTail_CanReadLogOpenForSharedWriting()
842886
{
@@ -1092,8 +1136,10 @@ public void GetPreferredLogDialogLayout_ReservesVisibleActionRowOutsideScroll()
10921136
LogDialogLayout layout = ModdingScreenDialogRules.GetPreferredLogDialogLayout();
10931137

10941138
Assert.IsTrue(layout.ActionRowHeight >= 40);
1139+
Assert.IsTrue(layout.ToolbarGap >= 6);
10951140
Assert.IsTrue(layout.ScrollHeight < layout.PanelHeight);
1096-
Assert.IsTrue(layout.ScrollHeight + layout.ActionRowHeight <= layout.PanelHeight);
1141+
Assert.IsTrue(layout.ScrollHeight + layout.ActionRowHeight > layout.PanelHeight);
1142+
Assert.IsTrue(layout.PanelHeight + layout.ActionRowHeight + layout.ToolbarGap < layout.PopupHeight);
10971143
Assert.IsTrue(layout.PopupHeight > layout.PanelHeight);
10981144
}
10991145

Data/LogLevelFilterService.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.Text;
2+
using System.Text.RegularExpressions;
3+
4+
namespace BetterModMenu.Data;
5+
6+
[Flags]
7+
internal enum LogLevelFilter
8+
{
9+
None = 0,
10+
Debug = 1 << 0,
11+
Info = 1 << 1,
12+
Warning = 1 << 2,
13+
Error = 1 << 3,
14+
Other = 1 << 4,
15+
All = Debug | Info | Warning | Error | Other
16+
}
17+
18+
internal static partial class LogLevelFilterService
19+
{
20+
public static string Filter(string content, LogLevelFilter includedLevels)
21+
{
22+
if (string.IsNullOrEmpty(content) || includedLevels == LogLevelFilter.All)
23+
return content;
24+
25+
var builder = new StringBuilder(content.Length);
26+
string normalized = content.Replace("\r\n", "\n").Replace('\r', '\n');
27+
string[] lines = normalized.Split('\n');
28+
bool appendedLine = false;
29+
30+
foreach (string line in lines)
31+
{
32+
LogLevelFilter level = Classify(line);
33+
if ((includedLevels & level) == 0)
34+
continue;
35+
36+
if (appendedLine)
37+
builder.Append('\n');
38+
builder.Append(line);
39+
appendedLine = true;
40+
}
41+
42+
return builder.ToString();
43+
}
44+
45+
public static LogLevelFilter Classify(string line)
46+
{
47+
string normalized = line
48+
.Replace("[lb]", "[", StringComparison.OrdinalIgnoreCase)
49+
.Replace("[rb]", "]", StringComparison.OrdinalIgnoreCase);
50+
51+
if (ContainsLevelToken(normalized, ErrorTokenRegex()))
52+
return LogLevelFilter.Error;
53+
if (ContainsLevelToken(normalized, WarningTokenRegex()))
54+
return LogLevelFilter.Warning;
55+
if (ContainsLevelToken(normalized, DebugTokenRegex()))
56+
return LogLevelFilter.Debug;
57+
if (ContainsLevelToken(normalized, InfoTokenRegex()))
58+
return LogLevelFilter.Info;
59+
if (IsErrorLine(normalized))
60+
return LogLevelFilter.Error;
61+
if (IsWarningLine(normalized))
62+
return LogLevelFilter.Warning;
63+
64+
return LogLevelFilter.Other;
65+
}
66+
67+
private static bool IsErrorLine(string line)
68+
{
69+
return line.Contains("WITH ERRORS", StringComparison.OrdinalIgnoreCase) ||
70+
line.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ||
71+
line.Contains("EXCEPTION", StringComparison.OrdinalIgnoreCase) ||
72+
line.Contains("FAILED", StringComparison.OrdinalIgnoreCase) ||
73+
line.Contains("[ERR", StringComparison.OrdinalIgnoreCase);
74+
}
75+
76+
private static bool IsWarningLine(string line)
77+
{
78+
return ContainsLevelToken(line, WarningTokenRegex());
79+
}
80+
81+
private static bool ContainsLevelToken(string line, Regex regex)
82+
{
83+
return regex.IsMatch(line);
84+
}
85+
86+
[GeneratedRegex(@"(^|[\s\[/\\])DEBUG(\]|\)|:|\s|/|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
87+
private static partial Regex DebugTokenRegex();
88+
89+
[GeneratedRegex(@"(^|[\s\[/\\])INFO(\]|\)|:|\s|/|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
90+
private static partial Regex InfoTokenRegex();
91+
92+
[GeneratedRegex(@"(^|[\s\[/\\])WARN(?:ING)?(\]|\)|:|\s|/|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
93+
private static partial Regex WarningTokenRegex();
94+
95+
[GeneratedRegex(@"(^|[\s\[/\\])ERR(?:OR)?(\]|\)|:|\s|/|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
96+
private static partial Regex ErrorTokenRegex();
97+
}

Data/TutorialContentBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public static string BuildBody()
1111
"Groups are labels for organizing the list. Type a group name, press Add, then use each mod row's group picker to move mods into that group. Group headers can collapse the section, move the group, rename it, delete it, or turn every mod in the group on or off together.",
1212
"Portable Mode stores Better Mod Menu's save file beside the mod files. Leave it off for the normal game save location; turn it on when you want this mod setup to travel with a copied game or mod folder.",
1313
"Backup saves copies of your Better Mod Menu profiles, groups, and the game's current enabled-mod settings, including Steam Workshop links when available. Load lets you choose a profile and group backup to restore. CSV creates a spreadsheet-friendly list of installed mods, versions, enabled state, group names, and Steam Workshop links when available.",
14-
"The Logs button opens full BetterModMenu/TTSMM log output with warnings and errors highlighted when you need to see what happened. Use Open Folder in the log viewer to open the folder that contains the log file.",
14+
"The Logs button opens full BetterModMenu/TTSMM log output with warnings and errors highlighted when you need to see what happened. Use its level toggles to show or hide debug, info, warning, error, and unclassified lines. Use Open Folder in the log viewer to open the folder that contains the log file.",
1515
"Cloud-capable builds can mirror backups and CSV exports to a synced folder, but cloud behavior stays opt-in.");
1616
}
1717
}

Patches/ModdingScreenDialogRules.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ internal readonly record struct LogDialogLayout(
2222
int ScrollHeight,
2323
int BodyFontSize,
2424
int ButtonFontSize,
25-
int ActionRowHeight);
25+
int ActionRowHeight,
26+
int ToolbarGap);
2627

2728
internal static class ModdingScreenDialogRules
2829
{
@@ -32,11 +33,12 @@ public static LogDialogLayout GetPreferredLogDialogLayout()
3233
PopupWidth: 1080,
3334
PopupHeight: 680,
3435
PanelWidth: 1020,
35-
PanelHeight: 600,
36-
ScrollHeight: 540,
36+
PanelHeight: 520,
37+
ScrollHeight: 508,
3738
BodyFontSize: 22,
3839
ButtonFontSize: 22,
39-
ActionRowHeight: 44);
40+
ActionRowHeight: 44,
41+
ToolbarGap: 8);
4042
}
4143

4244
public static TutorialDialogLayout GetPreferredTutorialDialogLayout()

Patches/ModdingScreenDialogs.cs

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -101,39 +101,42 @@ public static void ShowLogDialog(NModdingScreen screen, string title, string con
101101
DialogText = string.Empty
102102
};
103103

104-
var panel = new PanelContainer
105-
{
106-
CustomMinimumSize = new Vector2(layout.PanelWidth, layout.PanelHeight)
107-
};
108-
ModdingScreenVanillaStyle.ApplyLogPanel(panel);
109-
var panelBox = new VBoxContainer
104+
popup.AddChild(CreateLogDialogBody(layout, content, () => OpenLogFolder(screen, logPath)));
105+
ApplyReadableDialogButtons(popup, layout.ButtonFontSize);
106+
screen.AddChild(popup);
107+
popup.PopupCentered(new Vector2I(layout.PopupWidth, layout.PopupHeight));
108+
}
109+
110+
private static Control CreateLogDialogBody(LogDialogLayout layout, string content, Action onOpenFolderPressed)
111+
{
112+
LogLevelFilter includedLevels = LogLevelFilter.All;
113+
string displayedContent = content;
114+
var dialogBox = new VBoxContainer
110115
{
116+
CustomMinimumSize = new Vector2(layout.PanelWidth, layout.PanelHeight + layout.ActionRowHeight + layout.ToolbarGap),
111117
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
112118
SizeFlagsVertical = Control.SizeFlags.ExpandFill
113119
};
114-
var actionRow = new HBoxContainer
120+
dialogBox.AddThemeConstantOverride("separation", layout.ToolbarGap);
121+
122+
var toolbarPanel = new PanelContainer
115123
{
116-
CustomMinimumSize = new Vector2(0, layout.ActionRowHeight),
124+
CustomMinimumSize = new Vector2(layout.PanelWidth, layout.ActionRowHeight),
117125
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill
118126
};
119-
var copyButton = new Button
127+
ModdingScreenVanillaStyle.ApplyLogToolbarPanel(toolbarPanel);
128+
var actionRow = new HBoxContainer
120129
{
121-
Text = "Copy All",
122-
TooltipText = "Copy the full displayed log text to the clipboard"
130+
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
131+
CustomMinimumSize = new Vector2(0, layout.ActionRowHeight)
123132
};
124-
ModdingScreenVanillaStyle.ApplyButton(copyButton);
125-
copyButton.Pressed += () => DisplayServer.ClipboardSet(content);
126-
actionRow.AddChild(copyButton);
133+
actionRow.AddThemeConstantOverride("separation", 8);
127134

128-
var openFolderButton = new Button
135+
var panel = new PanelContainer
129136
{
130-
Text = "Open Folder",
131-
TooltipText = "Open the folder that contains this log file."
137+
CustomMinimumSize = new Vector2(layout.PanelWidth, layout.PanelHeight)
132138
};
133-
ModdingScreenVanillaStyle.ApplyButton(openFolderButton);
134-
openFolderButton.Pressed += () => OpenLogFolder(screen, logPath);
135-
actionRow.AddChild(openFolderButton);
136-
panelBox.AddChild(actionRow);
139+
ModdingScreenVanillaStyle.ApplyLogPanel(panel);
137140

138141
var scroll = new ScrollContainer
139142
{
@@ -151,7 +154,7 @@ public static void ShowLogDialog(NModdingScreen screen, string title, string con
151154
var label = new RichTextLabel
152155
{
153156
BbcodeEnabled = true,
154-
Text = LogHighlightService.BuildHighlightedBbCode(content),
157+
Text = LogHighlightService.BuildHighlightedBbCode(displayedContent),
155158
SelectionEnabled = true,
156159
ContextMenuEnabled = true,
157160
ScrollActive = false,
@@ -160,17 +163,84 @@ public static void ShowLogDialog(NModdingScreen screen, string title, string con
160163
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
161164
SizeFlagsVertical = Control.SizeFlags.ExpandFill
162165
};
163-
label.AddThemeColorOverride("default_color", new Color(0.92f, 0.86f, 0.74f, 1f));
166+
label.AddThemeColorOverride("default_color", new Color(0.96f, 0.91f, 0.82f, 1f));
167+
label.AddThemeColorOverride("selection_color", new Color(0.86f, 0.62f, 0.27f, 0.38f));
164168
label.AddThemeFontSizeOverride("font_size", layout.BodyFontSize);
165169

170+
void RefreshLogText()
171+
{
172+
displayedContent = LogLevelFilterService.Filter(content, includedLevels);
173+
label.Text = LogHighlightService.BuildHighlightedBbCode(displayedContent);
174+
}
175+
176+
var copyButton = new Button
177+
{
178+
Text = "Copy All",
179+
TooltipText = "Copy the full displayed log text to the clipboard"
180+
};
181+
ModdingScreenVanillaStyle.ApplyButton(copyButton);
182+
copyButton.Pressed += () => DisplayServer.ClipboardSet(displayedContent);
183+
actionRow.AddChild(copyButton);
184+
185+
var openFolderButton = new Button
186+
{
187+
Text = "Open Folder",
188+
TooltipText = "Open the folder that contains this log file."
189+
};
190+
ModdingScreenVanillaStyle.ApplyButton(openFolderButton);
191+
openFolderButton.Pressed += onOpenFolderPressed;
192+
actionRow.AddChild(openFolderButton);
193+
194+
var spacer = new Control
195+
{
196+
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
197+
};
198+
actionRow.AddChild(spacer);
199+
200+
var levelLabel = new Label
201+
{
202+
Text = "Levels",
203+
TooltipText = "Checked levels are shown. Uncheck a level to exclude it."
204+
};
205+
ModdingScreenVanillaStyle.ApplyLabel(levelLabel);
206+
levelLabel.AddThemeFontSizeOverride("font_size", layout.ButtonFontSize);
207+
actionRow.AddChild(levelLabel);
208+
209+
actionRow.AddChild(CreateLogLevelToggle("Debug", LogLevelFilter.Debug));
210+
actionRow.AddChild(CreateLogLevelToggle("Info", LogLevelFilter.Info));
211+
actionRow.AddChild(CreateLogLevelToggle("Warn", LogLevelFilter.Warning));
212+
actionRow.AddChild(CreateLogLevelToggle("Error", LogLevelFilter.Error));
213+
actionRow.AddChild(CreateLogLevelToggle("Other", LogLevelFilter.Other));
214+
166215
contentBox.AddChild(label);
167216
scroll.AddChild(contentBox);
168-
panelBox.AddChild(scroll);
169-
panel.AddChild(panelBox);
170-
popup.AddChild(panel);
171-
ApplyReadableDialogButtons(popup, layout.ButtonFontSize);
172-
screen.AddChild(popup);
173-
popup.PopupCentered(new Vector2I(layout.PopupWidth, layout.PopupHeight));
217+
panel.AddChild(scroll);
218+
toolbarPanel.AddChild(actionRow);
219+
dialogBox.AddChild(toolbarPanel);
220+
dialogBox.AddChild(panel);
221+
222+
CheckButton CreateLogLevelToggle(string text, LogLevelFilter level)
223+
{
224+
var toggle = new CheckButton
225+
{
226+
Text = text,
227+
ButtonPressed = true,
228+
TooltipText = "Show or hide " + text.ToLowerInvariant() + " log lines."
229+
};
230+
ModdingScreenVanillaStyle.ApplyButton(toggle);
231+
toggle.CustomMinimumSize = new Vector2(Mathf.Max(toggle.CustomMinimumSize.X, 82), 34);
232+
toggle.AddThemeFontSizeOverride("font_size", layout.ButtonFontSize);
233+
toggle.Toggled += pressed =>
234+
{
235+
includedLevels = pressed
236+
? includedLevels | level
237+
: includedLevels & ~level;
238+
RefreshLogText();
239+
};
240+
return toggle;
241+
}
242+
243+
return dialogBox;
174244
}
175245

176246
private static void OpenLogFolder(NModdingScreen screen, string logPath)

0 commit comments

Comments
 (0)