328 lines
8.5 KiB
C#
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"/> должно быть < 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;
|
|
}
|
|
}
|