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; /// /// Журнал в : цвет строки — ресурсные кисти и /// на Run /// (не new Run(...) { Foreground = Brushes...). /// public partial class LogsView { /// WPF: должно быть < 1_000_000 DIP. 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), }; } /// Ключи кистей в (не задаём через Brushes напрямую). 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; } }