9bb4f85802
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
454 lines
17 KiB
C#
454 lines
17 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Input;
|
|
using Avalonia.Input.Platform;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
|
|
namespace CVRManager.Views;
|
|
|
|
public class LogView : UserControl
|
|
{
|
|
public static readonly StyledProperty<string?> TextProperty = AvaloniaProperty.Register<LogView, string?>(nameof(Text));
|
|
|
|
readonly ScrollViewer scroll;
|
|
readonly LogSurface surface = new();
|
|
|
|
static LogView() => TextProperty.Changed.AddClassHandler<LogView>((c, _) => c.OnTextChanged());
|
|
|
|
public LogView()
|
|
{
|
|
scroll = new ScrollViewer
|
|
{
|
|
Content = surface,
|
|
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
|
};
|
|
scroll.ScrollChanged += (_, _) => surface.UpdateView(scroll.Offset, scroll.Viewport);
|
|
Content = new Border
|
|
{
|
|
Background = new SolidColorBrush(Color.Parse("#0B0D10")),
|
|
BorderBrush = new SolidColorBrush(Color.Parse("#232833")),
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(8),
|
|
ClipToBounds = true,
|
|
Child = scroll,
|
|
};
|
|
}
|
|
|
|
public string? Text
|
|
{
|
|
get => GetValue(TextProperty);
|
|
set => SetValue(TextProperty, value);
|
|
}
|
|
|
|
void OnTextChanged()
|
|
{
|
|
var atEnd = scroll.Extent.Height <= 0 || scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 24;
|
|
if (!surface.SetText(Text ?? "")) return;
|
|
if (atEnd) Dispatcher.UIThread.Post(() => scroll.ScrollToEnd(), DispatcherPriority.Background);
|
|
}
|
|
}
|
|
|
|
internal sealed class LogSurface : Control
|
|
{
|
|
const int MaxLines = 20000;
|
|
const int KeepLines = 15000;
|
|
|
|
static readonly IBrush DefaultBrush = new SolidColorBrush(Color.Parse("#E6E8EC"));
|
|
static readonly IBrush TimeBrush = new SolidColorBrush(Color.Parse("#6B7585"));
|
|
static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#7AA2FF"));
|
|
static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#F0646A"));
|
|
static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#F5B13D"));
|
|
static readonly IBrush SelectionBrush = new SolidColorBrush(Color.Parse("#553A5BA8"));
|
|
|
|
static readonly Regex TimeRx = new(@"^(\[\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)(\s*)(.*)$", RegexOptions.Compiled);
|
|
static readonly Regex TagRx = new(@"^(\s*(?:\[[^\]]{1,40}\]|\([^)]{1,100}\)))(.*)$", RegexOptions.Compiled);
|
|
static readonly Regex StackRx = new(@"^\s+at\s", RegexOptions.Compiled);
|
|
static readonly Regex FileLineRx = new(@"^\(Filename:", RegexOptions.Compiled);
|
|
static readonly Regex PipeLevelRx = new(@"\|\s*(?<l>error|exception|fatal|warn|warning|info|debug|trace)\s*\)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
static readonly Regex BracketLevelRx = new(@"\[(?<l>error|fatal|fail|exception|warn|warning)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
static readonly Regex LeadingLevelRx = new(@"^\s*(?<l>error|fatal|unhandled exception|exception|failed|failure|warning|warn)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
static readonly Regex ExceptionRx = new(@"(?:^|\s)(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*Exception:", RegexOptions.Compiled);
|
|
|
|
readonly record struct Seg(string Text, IBrush Brush);
|
|
|
|
static readonly Regex AnsiRx = new("\u001b\\[([0-9;]*)m", RegexOptions.Compiled);
|
|
static readonly Regex OtherEscRx = new("\u001b\\[[0-9;?]*[A-Za-ln-z]", RegexOptions.Compiled);
|
|
static readonly IBrush[] AnsiNormal = new[] { "#8B93A1", "#F0646A", "#4ADE9B", "#F5B13D", "#7AA2FF", "#C792EA", "#5FD3E0", "#E6E8EC" }
|
|
.Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray();
|
|
static readonly IBrush[] AnsiBright = new[] { "#A0A8B5", "#FF8087", "#6EF0B5", "#FFD166", "#9AB8FF", "#DDA8FF", "#8FEAF5", "#FFFFFF" }
|
|
.Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray();
|
|
|
|
static IBrush Rgb(int r, int g, int b) => new SolidColorBrush(Color.FromRgb((byte)Math.Clamp(r, 0, 255), (byte)Math.Clamp(g, 0, 255), (byte)Math.Clamp(b, 0, 255)));
|
|
|
|
static IBrush? ApplySgr(string codes, IBrush? current)
|
|
{
|
|
var parts = codes.Length == 0 ? new[] { "0" } : codes.Split(';');
|
|
for (var i = 0; i < parts.Length; i++)
|
|
{
|
|
if (!int.TryParse(parts[i], out var n)) continue;
|
|
if (n == 0 || n == 39) current = null;
|
|
else if (n >= 30 && n <= 37) current = AnsiNormal[n - 30];
|
|
else if (n >= 90 && n <= 97) current = AnsiBright[n - 90];
|
|
else if (n == 38 && i + 1 < parts.Length)
|
|
{
|
|
if (parts[i + 1] == "2" && i + 4 < parts.Length && int.TryParse(parts[i + 2], out var r) && int.TryParse(parts[i + 3], out var g) && int.TryParse(parts[i + 4], out var b))
|
|
{
|
|
current = Rgb(r, g, b);
|
|
i += 4;
|
|
}
|
|
else if (parts[i + 1] == "5" && i + 2 < parts.Length && int.TryParse(parts[i + 2], out var idx))
|
|
{
|
|
if (idx < 8) current = AnsiNormal[idx];
|
|
else if (idx < 16) current = AnsiBright[idx - 8];
|
|
else if (idx < 232)
|
|
{
|
|
var c = idx - 16;
|
|
int Level(int v) => v == 0 ? 0 : 55 + v * 40;
|
|
current = Rgb(Level(c / 36), Level(c / 6 % 6), Level(c % 6));
|
|
}
|
|
else { var v = 8 + (idx - 232) * 10; current = Rgb(v, v, v); }
|
|
i += 2;
|
|
}
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
static Line ParseAnsi(string raw, IBrush? carried)
|
|
{
|
|
raw = OtherEscRx.Replace(raw, "");
|
|
var segs = new List<Seg>(4);
|
|
var brush = carried;
|
|
var pos = 0;
|
|
foreach (Match m in AnsiRx.Matches(raw))
|
|
{
|
|
if (m.Index > pos) segs.Add(new Seg(raw[pos..m.Index], brush ?? DefaultBrush));
|
|
brush = ApplySgr(m.Groups[1].Value, brush);
|
|
pos = m.Index + m.Length;
|
|
}
|
|
if (pos < raw.Length) segs.Add(new Seg(raw[pos..], brush ?? DefaultBrush));
|
|
return new Line { Text = string.Concat(segs.Select(s => s.Text)), Segs = segs.ToArray(), AnsiEnd = brush };
|
|
}
|
|
|
|
sealed class Line
|
|
{
|
|
public IBrush? AnsiEnd;
|
|
public string Text = "";
|
|
public Seg[] Segs = Array.Empty<Seg>();
|
|
public IBrush? Level;
|
|
}
|
|
|
|
readonly List<Line> lines = new();
|
|
readonly Dictionary<Line, FormattedText[]> formatted = new();
|
|
readonly Typeface typeface = new(new FontFamily("Cascadia Mono,Consolas,monospace"));
|
|
readonly double fontSize = 12;
|
|
readonly Thickness pad = new(12, 8);
|
|
readonly double charWidth;
|
|
readonly double lineHeight;
|
|
|
|
string shown = "";
|
|
int maxCols;
|
|
Vector offset;
|
|
Size viewport;
|
|
(int Line, int Col)? anchor;
|
|
(int Line, int Col)? caret;
|
|
bool dragging;
|
|
|
|
public LogSurface()
|
|
{
|
|
var probe = Format("0000000000", DefaultBrush);
|
|
charWidth = probe.Width / 10;
|
|
lineHeight = Math.Ceiling(probe.Height) + 1;
|
|
Focusable = true;
|
|
Cursor = new Cursor(StandardCursorType.Ibeam);
|
|
ClipToBounds = true;
|
|
}
|
|
|
|
FormattedText Format(string text, IBrush brush) =>
|
|
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, fontSize, brush);
|
|
|
|
static IBrush? FromWord(string word) => word.ToLowerInvariant() switch
|
|
{
|
|
"error" or "fatal" or "fail" or "exception" or "failed" or "failure" or "unhandled exception" => ErrorBrush,
|
|
"warn" or "warning" => WarnBrush,
|
|
_ => null,
|
|
};
|
|
|
|
static IBrush? LevelOfLine(string line)
|
|
{
|
|
var pipe = PipeLevelRx.Match(line);
|
|
if (pipe.Success) return FromWord(pipe.Groups["l"].Value);
|
|
|
|
var head = line.Length > 160 ? line[..160] : line;
|
|
var bracket = BracketLevelRx.Match(head);
|
|
if (bracket.Success) return FromWord(bracket.Groups["l"].Value);
|
|
|
|
var message = line;
|
|
var time = TimeRx.Match(message);
|
|
if (time.Success) message = time.Groups[3].Value;
|
|
var tag = TagRx.Match(message);
|
|
while (tag.Success)
|
|
{
|
|
message = tag.Groups[2].Value;
|
|
tag = TagRx.Match(message);
|
|
}
|
|
|
|
var leading = LeadingLevelRx.Match(message);
|
|
if (leading.Success) return FromWord(leading.Groups["l"].Value);
|
|
return ExceptionRx.IsMatch(message) ? ErrorBrush : null;
|
|
}
|
|
|
|
static Line Parse(string text, IBrush? inherited)
|
|
{
|
|
var line = new Line { Text = text, Level = inherited };
|
|
var segs = new List<Seg>(4);
|
|
if (StackRx.IsMatch(text)) segs.Add(new Seg(text, inherited ?? DefaultBrush));
|
|
else if (FileLineRx.IsMatch(text)) segs.Add(new Seg(text, TimeBrush));
|
|
else
|
|
{
|
|
var level = LevelOfLine(text);
|
|
line.Level = level;
|
|
var rest = text;
|
|
var time = TimeRx.Match(rest);
|
|
if (time.Success)
|
|
{
|
|
segs.Add(new Seg(time.Groups[1].Value, TimeBrush));
|
|
if (time.Groups[2].Length > 0) segs.Add(new Seg(time.Groups[2].Value, DefaultBrush));
|
|
rest = time.Groups[3].Value;
|
|
}
|
|
var tag = TagRx.Match(rest);
|
|
if (tag.Success)
|
|
{
|
|
segs.Add(new Seg(tag.Groups[1].Value, level ?? TagBrush));
|
|
rest = tag.Groups[2].Value;
|
|
}
|
|
if (rest.Length > 0) segs.Add(new Seg(rest, level ?? DefaultBrush));
|
|
}
|
|
line.Segs = segs.ToArray();
|
|
return line;
|
|
}
|
|
|
|
void AppendLines(string chunk)
|
|
{
|
|
var parts = chunk.Split('\n');
|
|
var count = parts.Length;
|
|
if (count > 0 && parts[^1].Length == 0) count--;
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var text = parts[i].TrimEnd('\r');
|
|
var carried = lines.Count > 0 ? lines[^1].AnsiEnd : null;
|
|
var line = carried != null || text.IndexOf('\u001b') >= 0
|
|
? ParseAnsi(text, carried)
|
|
: Parse(text, lines.Count > 0 ? lines[^1].Level : null);
|
|
text = line.Text;
|
|
lines.Add(line);
|
|
if (text.Length > maxCols) maxCols = text.Length;
|
|
}
|
|
}
|
|
|
|
void TrimHead()
|
|
{
|
|
if (lines.Count <= MaxLines) return;
|
|
var drop = lines.Count - KeepLines;
|
|
foreach (var l in lines.Take(drop)) formatted.Remove(l);
|
|
lines.RemoveRange(0, drop);
|
|
maxCols = lines.Count == 0 ? 0 : lines.Max(l => l.Text.Length);
|
|
anchor = anchor is { } a ? (Math.Max(0, a.Line - drop), a.Line - drop < 0 ? 0 : a.Col) : null;
|
|
caret = caret is { } c ? (Math.Max(0, c.Line - drop), c.Line - drop < 0 ? 0 : c.Col) : null;
|
|
}
|
|
|
|
public bool SetText(string text)
|
|
{
|
|
if (text == shown) return false;
|
|
|
|
string? appended = null;
|
|
var endsWithNewline = shown.Length == 0 || shown[^1] == '\n';
|
|
if (shown.Length > 0 && text.StartsWith(shown, StringComparison.Ordinal)) appended = text[shown.Length..];
|
|
else if (shown.Length > 0)
|
|
{
|
|
var key = shown.Length > 400 ? shown[^400..] : shown;
|
|
var at = text.LastIndexOf(key, StringComparison.Ordinal);
|
|
if (at >= 0) appended = text[(at + key.Length)..];
|
|
}
|
|
|
|
if (appended == null)
|
|
{
|
|
lines.Clear();
|
|
formatted.Clear();
|
|
maxCols = 0;
|
|
anchor = caret = null;
|
|
AppendLines(text);
|
|
}
|
|
else
|
|
{
|
|
var partial = "";
|
|
if (!endsWithNewline && lines.Count > 0)
|
|
{
|
|
partial = lines[^1].Text;
|
|
formatted.Remove(lines[^1]);
|
|
lines.RemoveAt(lines.Count - 1);
|
|
}
|
|
AppendLines(partial + appended);
|
|
}
|
|
|
|
TrimHead();
|
|
shown = text;
|
|
InvalidateMeasure();
|
|
InvalidateVisual();
|
|
return true;
|
|
}
|
|
|
|
public void UpdateView(Vector newOffset, Size newViewport)
|
|
{
|
|
var resized = newViewport != viewport;
|
|
offset = newOffset;
|
|
viewport = newViewport;
|
|
if (resized) InvalidateMeasure();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override Size MeasureOverride(Size availableSize)
|
|
{
|
|
var width = pad.Left + pad.Right + maxCols * charWidth;
|
|
var height = pad.Top + pad.Bottom + lines.Count * lineHeight;
|
|
return new Size(Math.Max(width, viewport.Width), Math.Max(height, viewport.Height));
|
|
}
|
|
|
|
public override void Render(DrawingContext context)
|
|
{
|
|
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
|
if (lines.Count == 0) return;
|
|
|
|
var first = Math.Max(0, (int)((offset.Y - pad.Top) / lineHeight));
|
|
var last = Math.Min(lines.Count - 1, (int)((offset.Y + viewport.Height - pad.Top) / lineHeight) + 1);
|
|
var (selStart, selEnd) = Selection();
|
|
|
|
if (formatted.Count > 600) formatted.Clear();
|
|
for (var i = first; i <= last; i++)
|
|
{
|
|
var line = lines[i];
|
|
var y = pad.Top + i * lineHeight;
|
|
|
|
if (selStart is { } s && selEnd is { } e && i >= s.Line && i <= e.Line)
|
|
{
|
|
var c0 = i == s.Line ? s.Col : 0;
|
|
var c1 = i == e.Line ? e.Col : line.Text.Length + 1;
|
|
if (c1 > c0) context.FillRectangle(SelectionBrush, new Rect(pad.Left + c0 * charWidth, y, (c1 - c0) * charWidth, lineHeight));
|
|
}
|
|
|
|
if (!formatted.TryGetValue(line, out var fts))
|
|
formatted[line] = fts = line.Segs.Select(sg => Format(sg.Text, sg.Brush)).ToArray();
|
|
|
|
var x = pad.Left;
|
|
for (var k = 0; k < line.Segs.Length; k++)
|
|
{
|
|
var width = line.Segs[k].Text.Length * charWidth;
|
|
if (x + width >= offset.X && x <= offset.X + viewport.Width)
|
|
context.DrawText(fts[k], new Point(x, y));
|
|
x += width;
|
|
}
|
|
}
|
|
}
|
|
|
|
(int Line, int Col) HitTest(Point p)
|
|
{
|
|
if (lines.Count == 0) return (0, 0);
|
|
var line = Math.Clamp((int)Math.Floor((p.Y - pad.Top) / lineHeight), 0, lines.Count - 1);
|
|
var col = Math.Clamp((int)Math.Round((p.X - pad.Left) / charWidth), 0, lines[line].Text.Length);
|
|
return (line, col);
|
|
}
|
|
|
|
((int Line, int Col)? Start, (int Line, int Col)? End) Selection()
|
|
{
|
|
if (anchor is not { } a || caret is not { } c || a == c) return (null, null);
|
|
var forward = a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col);
|
|
return forward ? (a, c) : (c, a);
|
|
}
|
|
|
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
|
{
|
|
base.OnPointerPressed(e);
|
|
Focus();
|
|
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
|
var hit = HitTest(e.GetPosition(this));
|
|
if (e.ClickCount == 2 && lines.Count > 0)
|
|
{
|
|
var text = lines[hit.Line].Text;
|
|
int start = hit.Col, end = hit.Col;
|
|
while (start > 0 && !char.IsWhiteSpace(text[start - 1])) start--;
|
|
while (end < text.Length && !char.IsWhiteSpace(text[end])) end++;
|
|
anchor = (hit.Line, start);
|
|
caret = (hit.Line, end);
|
|
}
|
|
else if (e.ClickCount >= 3 && lines.Count > 0)
|
|
{
|
|
anchor = (hit.Line, 0);
|
|
caret = (hit.Line, lines[hit.Line].Text.Length);
|
|
}
|
|
else
|
|
{
|
|
anchor = hit;
|
|
caret = hit;
|
|
dragging = true;
|
|
}
|
|
e.Pointer.Capture(this);
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override void OnPointerMoved(PointerEventArgs e)
|
|
{
|
|
base.OnPointerMoved(e);
|
|
if (!dragging) return;
|
|
caret = HitTest(e.GetPosition(this));
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
|
{
|
|
base.OnPointerReleased(e);
|
|
dragging = false;
|
|
e.Pointer.Capture(null);
|
|
}
|
|
|
|
string SelectedText()
|
|
{
|
|
var (start, end) = Selection();
|
|
if (start is not { } s || end is not { } e) return "";
|
|
var sb = new StringBuilder();
|
|
for (var i = s.Line; i <= e.Line && i < lines.Count; i++)
|
|
{
|
|
var text = lines[i].Text;
|
|
var c0 = i == s.Line ? Math.Min(s.Col, text.Length) : 0;
|
|
var c1 = i == e.Line ? Math.Min(e.Col, text.Length) : text.Length;
|
|
sb.Append(text, c0, Math.Max(0, c1 - c0));
|
|
if (i < e.Line) sb.Append('\n');
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
protected override async void OnKeyDown(KeyEventArgs e)
|
|
{
|
|
base.OnKeyDown(e);
|
|
if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) return;
|
|
if (e.Key == Key.A && lines.Count > 0)
|
|
{
|
|
anchor = (0, 0);
|
|
caret = (lines.Count - 1, lines[^1].Text.Length);
|
|
InvalidateVisual();
|
|
e.Handled = true;
|
|
}
|
|
else if (e.Key == Key.C)
|
|
{
|
|
var text = SelectedText();
|
|
if (text.Length > 0 && TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) await clipboard.SetTextAsync(text);
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
}
|