emby-toolbox/EmbyToolbox/Views/LogsView.xaml.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

328 lines
8.5 KiB
C#

using System.Collections.Specialized;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Threading;
using EmbyToolbox.Services;
using EmbyToolbox.ViewModels;
namespace EmbyToolbox.Views;
/// <summary>
/// Журнал в <see cref="RichTextBox"/>: цвет строки — ресурсные кисти и
/// <see cref="DependencyObject.SetResourceReference(System.Windows.DependencyProperty,object)"/> на Run
/// (не <c>new Run(...) { Foreground = Brushes...</c>).
/// </summary>
public partial class LogsView
{
/// <remarks>WPF: <see cref="FlowDocument.PageWidth"/> должно быть &lt; 1_000_000 DIP.</remarks>
private const double FlowDocumentSafePageWidth = 999_999.0;
private const double BottomEpsilonPx = 4.0;
private readonly FlowDocument _document;
private LogsViewModel? _vm;
private ScrollViewer? _scrollViewer;
private ScrollChangedEventHandler? _scrollChangedHandler;
private bool _stickToBottom = true;
public LogsView()
{
InitializeComponent();
_document = new FlowDocument
{
PagePadding = new Thickness(0),
Background = Brushes.Transparent,
Foreground = Brushes.Black,
FontFamily = new FontFamily("Consolas,Cascadia Mono"),
FontSize = 13,
PageWidth = FlowDocumentSafePageWidth,
TextAlignment = TextAlignment.Left,
};
LogRichTextBox.Document = _document;
Loaded += (_, _) => AttachBindings();
Unloaded += (_, _) => DetachBindingsCore();
DataContextChanged += (_, _) => AttachBindings();
}
private void AttachBindings()
{
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, AttachCore);
}
private void AttachCore()
{
if (!IsLoaded)
{
return;
}
if (LogRichTextBox is null)
{
return;
}
if (DataContext is not LogsViewModel vm)
{
DetachBindingsCore();
return;
}
if (ReferenceEquals(_vm, vm))
{
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(TryHookScrollWatcher));
return;
}
DetachBindingsCore();
_vm = vm;
_vm.PropertyChanged += OnVmPropertyChanged;
_vm.UiEntries.CollectionChanged += OnUiEntriesChanged;
RebuildFull();
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() =>
{
TryHookScrollWatcher();
_stickToBottom = true;
ScrollToEnd();
}));
}
private void DetachBindingsCore()
{
UnhookScrollWatcher();
_document.Blocks.Clear();
if (_vm is null)
{
return;
}
_vm.PropertyChanged -= OnVmPropertyChanged;
_vm.UiEntries.CollectionChanged -= OnUiEntriesChanged;
_vm = null;
}
private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LogsViewModel.ScrollPulse))
{
_stickToBottom = true;
ScrollToEnd();
}
}
private void OnUiEntriesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (_vm is null)
{
return;
}
switch (e.Action)
{
case NotifyCollectionChangedAction.Reset:
_document.Blocks.Clear();
_stickToBottom = true;
ScrollIfSticky();
break;
case NotifyCollectionChangedAction.Add:
if (TryAppendAdds(e))
{
ScrollIfSticky();
return;
}
goto default;
case NotifyCollectionChangedAction.Remove:
if (TryRemoveFromHead(e))
{
ScrollIfSticky();
return;
}
goto default;
default:
RebuildFull();
ScrollIfSticky();
break;
}
}
private bool TryAppendAdds(NotifyCollectionChangedEventArgs e)
{
if (e.NewItems is null || e.NewStartingIndex != _vm!.UiEntries.Count - e.NewItems.Count)
{
return false;
}
if (_document.Blocks.Count != _vm.UiEntries.Count - e.NewItems.Count)
{
return false;
}
foreach (LogEntryViewModel item in e.NewItems)
{
_document.Blocks.Add(CreateParagraph(item));
}
return true;
}
private bool TryRemoveFromHead(NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is null
|| e.OldStartingIndex != 0
|| _document.Blocks.Count != _vm!.UiEntries.Count + e.OldItems.Count)
{
return false;
}
for (var i = 0; i < e.OldItems.Count; i++)
{
var first = _document.Blocks.FirstBlock;
if (first is null)
{
return false;
}
_document.Blocks.Remove(first);
}
return true;
}
private void RebuildFull()
{
if (_vm is null)
{
return;
}
_document.Blocks.Clear();
foreach (var entry in _vm.UiEntries)
{
_document.Blocks.Add(CreateParagraph(entry));
}
}
private static Paragraph CreateParagraph(LogEntryViewModel entry)
{
var run = new Run(entry.DisplayText);
run.SetResourceReference(TextElement.ForegroundProperty, LogLevelToForegroundResourceKey(entry.Level));
return new Paragraph(run)
{
LineHeight = double.NaN,
Margin = new Thickness(0),
};
}
/// <summary>Ключи кистей в <see cref="RichTextBox.Resources"/> (не задаём <see cref="Run.Foreground"/> через Brushes напрямую).</summary>
private static object LogLevelToForegroundResourceKey(LogLevel level) =>
level switch
{
LogLevel.Debug => "LogDebugBrush",
LogLevel.Info => "LogInfoBrush",
LogLevel.Warning => "LogWarningBrush",
LogLevel.Error => "LogErrorBrush",
_ => "LogInfoBrush",
};
private void TryHookScrollWatcher()
{
if (LogRichTextBox is null)
{
return;
}
var sv = FindDescendantScrollViewer(LogRichTextBox);
if (sv is null)
{
return;
}
if (ReferenceEquals(sv, _scrollViewer))
{
RefreshStickyFromScroller();
return;
}
UnhookScrollWatcher();
_scrollViewer = sv;
_scrollChangedHandler = OnScrollViewerScrollChanged;
_scrollViewer.ScrollChanged += _scrollChangedHandler;
RefreshStickyFromScroller();
}
private void UnhookScrollWatcher()
{
if (_scrollViewer is not null && _scrollChangedHandler is not null)
{
_scrollViewer.ScrollChanged -= _scrollChangedHandler;
}
_scrollViewer = null;
_scrollChangedHandler = null;
}
private void OnScrollViewerScrollChanged(object? _, ScrollChangedEventArgs __) =>
RefreshStickyFromScroller();
private void RefreshStickyFromScroller()
{
if (_scrollViewer is null)
{
return;
}
var sv = _scrollViewer;
_stickToBottom = sv.ScrollableHeight <= 0
|| sv.VerticalOffset >= sv.ScrollableHeight - BottomEpsilonPx;
}
private void ScrollIfSticky()
{
if (!_stickToBottom)
{
return;
}
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(ScrollToEnd));
}
private void ScrollToEnd()
{
TryHookScrollWatcher();
LogRichTextBox.CaretPosition = LogRichTextBox.Document.ContentEnd;
LogRichTextBox.ScrollToEnd();
RefreshStickyFromScroller();
}
private static ScrollViewer? FindDescendantScrollViewer(DependencyObject root)
{
if (root is ScrollViewer viewer)
{
return viewer;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var child = VisualTreeHelper.GetChild(root, i);
var nested = FindDescendantScrollViewer(child);
if (nested is not null)
{
return nested;
}
}
return null;
}
}