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 TextProperty = AvaloniaProperty.Register(nameof(Text)); readonly ScrollViewer scroll; readonly LogSurface surface = new(); static LogView() => TextProperty.Changed.AddClassHandler((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*(?error|exception|fatal|warn|warning|info|debug|trace)\s*\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); static readonly Regex BracketLevelRx = new(@"\[(?error|fatal|fail|exception|warn|warning)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); static readonly Regex LeadingLevelRx = new(@"^\s*(?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(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(); public IBrush? Level; } readonly List lines = new(); readonly Dictionary 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(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; } } }