86

I seek advice for the best approach to implement a console-log viewer with WPF.

It should match the following criteria:

  • fast scrolling with 100.000+ lines
  • Some entries (like stacktraces) should be foldable
  • long items wrap
  • the list can be filtered by different criteria (searching, tags, etc)
  • when at the end, it should keep scrolling when new items are added
  • Line-elements can contain some sort of addition formatting like hyperlinks and occurrence counter

In general I have something in mind like the console window of FireBug and Chrome.

I played around with this but I didn't make much progress, because... - the datagrid can't handle different item heights - the scroll position is only updated after releasing the scrollbar (which is completely unacceptable).

I'm pretty sure, I need some form of virtualization and would love to follow the MVVM pattern.

Any help or pointers are welcome.

2
  • Are you sure you need to implement your own log viewer? This is kind of re-inventing the wheel... Can you use 3rd party tools to view your logs? For example, you can open DbgView and it will capture logs that are sent via Windows API. you can then broadcast logs that will be captured in the tool, for easy browsing and filtering Commented May 24, 2013 at 21:42
  • 1
    Excellent question. I need this component as part of an existing WPF application. We already have a "console" which is implemented as a frustratingly slow TextBox. But now we need the additional features I described. I'm very happy to reusing existing commercial or free non-GPL components. Commented May 24, 2013 at 21:50

3 Answers 3

220

I should start selling these WPF samples instead of giving them out for free. =P

enter image description here

  • Virtualized UI (Using VirtualizingStackPanel) which provides incredibly good performance (even with 200000+ items)
  • Fully MVVM-friendly.
  • DataTemplates for each kind of LogEntry type. These give you the ability to customize as much as you want. I only implemented 2 kinds of LogEntries (basic and nested), but you get the idea. You may subclass LogEntry as much as you need. You may even support rich text or images.
  • Expandable (Nested) Items.
  • Word Wrap.
  • You can implement filtering, etc. by using a CollectionView.
  • WPF Rocks, just copy and paste my code in a File -> New -> WPF Application and see the results for yourself.
<Window x:Class="MiscSamples.LogViewer"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:MiscSamples"
    Title="LogViewer" Height="500" Width="800">
<Window.Resources>
    <Style TargetType="ItemsControl" x:Key="LogViewerStyle">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate>
                    <ScrollViewer CanContentScroll="True">
                        <ItemsPresenter/>
                    </ScrollViewer>
                </ControlTemplate>
            </Setter.Value>
        </Setter>

        <Setter Property="ItemsPanel">
            <Setter.Value>
                <ItemsPanelTemplate>
                    <VirtualizingStackPanel IsItemsHost="True"/>
                </ItemsPanelTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <DataTemplate DataType="{x:Type local:LogEntry}">
        <Grid IsSharedSizeScope="True">
            <Grid.ColumnDefinitions>
                <ColumnDefinition SharedSizeGroup="Index" Width="Auto"/>
                <ColumnDefinition SharedSizeGroup="Date" Width="Auto"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>

            <TextBlock Text="{Binding DateTime}" Grid.Column="0"
                       FontWeight="Bold" Margin="5,0,5,0"/>

            <TextBlock Text="{Binding Index}" Grid.Column="1"
                       FontWeight="Bold" Margin="0,0,2,0" />

            <TextBlock Text="{Binding Message}" Grid.Column="2"
                       TextWrapping="Wrap"/>
        </Grid>
    </DataTemplate>

    <DataTemplate DataType="{x:Type local:CollapsibleLogEntry}">
        <Grid IsSharedSizeScope="True">
            <Grid.ColumnDefinitions>
                <ColumnDefinition SharedSizeGroup="Index" Width="Auto"/>
                <ColumnDefinition SharedSizeGroup="Date" Width="Auto"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>

            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition/>
            </Grid.RowDefinitions>

            <TextBlock Text="{Binding DateTime}" Grid.Column="0"
                       FontWeight="Bold" Margin="5,0,5,0"/>

            <TextBlock Text="{Binding Index}" Grid.Column="1"
                       FontWeight="Bold" Margin="0,0,2,0" />

            <TextBlock Text="{Binding Message}" Grid.Column="2"
                       TextWrapping="Wrap"/>

            <ToggleButton x:Name="Expander" Grid.Row="1" Grid.Column="0"
                          VerticalAlignment="Top" Content="+" HorizontalAlignment="Right"/>

            <ItemsControl ItemsSource="{Binding Contents}" Style="{StaticResource LogViewerStyle}"
                          Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2"
                          x:Name="Contents" Visibility="Collapsed"/>

        </Grid>
        <DataTemplate.Triggers>
            <Trigger SourceName="Expander" Property="IsChecked" Value="True">
                <Setter TargetName="Contents" Property="Visibility" Value="Visible"/>
                <Setter TargetName="Expander" Property="Content" Value="-"/>
            </Trigger>
        </DataTemplate.Triggers>
    </DataTemplate>
</Window.Resources>

<DockPanel>
    <TextBlock Text="{Binding Count, StringFormat='{}{0} Items'}"
               DockPanel.Dock="Top"/>

    <ItemsControl ItemsSource="{Binding}" Style="{StaticResource LogViewerStyle}">
        <ItemsControl.Template>
            <ControlTemplate>
                <ScrollViewer CanContentScroll="True">
                    <ItemsPresenter/>
                </ScrollViewer>
            </ControlTemplate>
        </ItemsControl.Template>
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <VirtualizingStackPanel IsItemsHost="True"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
</DockPanel>
</Window>

Code Behind: (Notice that most of it is just boilerplate to support the example (generate random entries)

public partial class LogViewer : Window
{
    private string TestData = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum";
    private List<string> words;
    private int maxword;
    private int index;

    public ObservableCollection<LogEntry> LogEntries { get; set; }

    public LogViewer()
    {
        InitializeComponent();

        random = new Random();
        words = TestData.Split(' ').ToList();
        maxword = words.Count - 1;

        DataContext = LogEntries = new ObservableCollection<LogEntry>();
        Enumerable.Range(0, 200000)
                  .ToList()
                  .ForEach(x => LogEntries.Add(GetRandomEntry()));

        Timer = new Timer(x => AddRandomEntry(), null, 1000, 10);
    }

    private System.Threading.Timer Timer;
    private System.Random random;
    private void AddRandomEntry()
    {
        Dispatcher.BeginInvoke((Action) (() => LogEntries.Add(GetRandomEntry())));
    }

    private LogEntry GetRandomEntry()
    {
        if (random.Next(1,10) > 1)
        {
            return new LogEntry
            {
                Index = index++,
                DateTime = DateTime.Now,
                Message = string.Join(" ", Enumerable.Range(5, random.Next(10, 50))
                                                     .Select(x => words[random.Next(0, maxword)])),
            };
        }

        return new CollapsibleLogEntry
        {
            Index = index++,
            DateTime = DateTime.Now,
            Message = string.Join(" ", Enumerable.Range(5, random.Next(10, 50))
                                                 .Select(x => words[random.Next(0, maxword)])),
            Contents = Enumerable.Range(5, random.Next(5, 10))
                                 .Select(i => GetRandomEntry())
                                 .ToList()
        };
    }
}

Data Items:

public class LogEntry : PropertyChangedBase
{
    public DateTime DateTime { get; set; }

    public int Index { get; set; }

    public string Message { get; set; }
}

public class CollapsibleLogEntry: LogEntry
{
    public List<LogEntry> Contents { get; set; }
}

PropertyChangedBase:

public class PropertyChangedBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName)
    {
        Application.Current.Dispatcher.BeginInvoke((Action) (() =>
        {
            PropertyChangedEventHandler handler = PropertyChanged;
            if (handler != null)
                handler(this, new PropertyChangedEventArgs(propertyName));
        }));
    }
}
Sign up to request clarification or add additional context in comments.

12 Comments

Wow! Did you just write this?! This is truly amazing. I just tested it and it's pretty much the perfect answer to my question. It looks like fleshing out the details should be straight foward. I'm blown away. Thanks a lot!
If you would be for hire to answer questions like that once in a while, I would be very happy to pay. :-)
@SinJeong-hun that's because of the nested ScrollViewers, you can try setting a different ControlTemplate to the nested ItemsControls.
Or you can just hook up the nested ScrollViewer's scroll to the parent scrollviewer. Shouldn't be that hard. The only problem here is you can't copy paste the log since it is a textblock. But easy fix it make it a textbox and readonly. :P
@user1034912 if you have 10k logs per second you clearly have a much bigger problem than just showing them in a log viewer application.
|
27

HighCore answer is perfect, but I guess it's missing this requirement:"when at the end, it should keep scrolling when new items are added".

According to this answer, you can do this:

In the main ScrollViewer (inside the DockPanel), add the event:

<ScrollViewer CanContentScroll="True" ScrollChanged="ScrollViewer_ScrollChanged">

Cast the event source to do the auto scroll:

    private bool AutoScroll = true;
    private void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        // User scroll event : set or unset autoscroll mode
        if (e.ExtentHeightChange == 0)
        {   // Content unchanged : user scroll event
            if ((e.Source as ScrollViewer).VerticalOffset == (e.Source as ScrollViewer).ScrollableHeight)
            {   // Scroll bar is in bottom
                // Set autoscroll mode
                AutoScroll = true;
            }
            else
            {   // Scroll bar isn't in bottom
                // Unset autoscroll mode
                AutoScroll = false;
            }
        }

        // Content scroll event : autoscroll eventually
        if (AutoScroll && e.ExtentHeightChange != 0)
        {   // Content changed and autoscroll mode set
            // Autoscroll
            (e.Source as ScrollViewer).ScrollToVerticalOffset((e.Source as ScrollViewer).ExtentHeight);
        }
    }
}

3 Comments

AutoScroll variable is an exception
Thanks. Fixed now.
This doesn't work and occasionally gives me a null exception on e.source
0

I implemented a universal auto-scroll behavior that pauses when the user intervenes with manual scrolling and resumes auto-scrolling once the scrollbar reaches the bottom.

The code uses var sv = VisualHelper.GetChild<ScrollViewer>(itemsControl);. You can find a similar implementation in the link below:

https://rachel53461.wordpress.com/2011/10/09/navigating-wpfs-visual-tree/

<DataGrid b:AutoScrollBehavior.IsEnabled="True"
          b:AutoScrollBehavior.AllowPauseAutoScroll="True"
          b:AutoScrollBehavior.AutoScrollVertical="True"
          b:AutoScrollBehavior.AutoScrollHorizontal="False"
          ItemsSource="{Binding xxx}"/>
public static class AutoScrollBehavior
{
    #region AutoScrollState

    private sealed class AutoScrollState : IDisposable
    {
        public bool AllowPauseAutoScroll { get; set; }

        public bool AutoScrollHorizontal
        {
            get;
            set
            {
                field = value;
                IsAutoHorizontalScrolling = value;
            }
        }

        public bool AutoScrollVertical
        {
            get;
            set
            {
                field = value;
                IsAutoVerticalScrolling = value;
            }
        }

        public bool IsAutoHorizontalScrolling { get; private set; }

        public bool IsAutoVerticalScrolling { get; private set; }

        public ScrollViewer? ScrollViewer { get; set; }

        #region Scroll Handling

        public void OnScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            if (ScrollViewer is null || !AllowPauseAutoScroll)
            {
                IsAutoVerticalScrolling = AutoScrollVertical;
                IsAutoHorizontalScrolling = AutoScrollHorizontal;

                return;
            }

            if (e.ExtentHeightChange == 0)
            {
                IsAutoVerticalScrolling = AutoScrollVertical && Math.Abs(ScrollViewer.VerticalOffset - ScrollViewer.ScrollableHeight) < 1;
            }

            if (e.ExtentWidthChange == 0)
            {
                IsAutoHorizontalScrolling = AutoScrollHorizontal && Math.Abs(ScrollViewer.HorizontalOffset - ScrollViewer.ScrollableWidth) < 1;
            }

            if (IsAutoVerticalScrolling && e.ExtentHeightChange != 0)
            {
                ScrollViewer.ScrollToBottom();
            }

            if (IsAutoHorizontalScrolling && e.ExtentWidthChange != 0)
            {
                ScrollViewer.ScrollToRightEnd();
            }
        }

        public void OnSizeChanged(object sender, SizeChangedEventArgs e)
        {
            if (ScrollViewer is null)
            {
                return;
            }

            if (IsAutoVerticalScrolling && AutoScrollVertical)
            {
                ScrollViewer.ScrollToBottom();
            }

            if (IsAutoHorizontalScrolling && AutoScrollHorizontal)
            {
                ScrollViewer.ScrollToRightEnd();
            }
        }

        #endregion Scroll Handling

        #region IDisposable

        public void Dispose()
        {
            if (ScrollViewer is not null)
            {
                ScrollViewer.ScrollChanged -= OnScrollChanged;
                ScrollViewer.SizeChanged -= OnSizeChanged;
                ScrollViewer = null;
            }
        }

        #endregion IDisposable
    }

    #endregion AutoScrollState

    #region Attached Properties

    public static readonly DependencyProperty AllowPauseAutoScrollProperty =
        DependencyProperty.RegisterAttached(
            "AllowPauseAutoScroll",
            typeof(bool),
            typeof(AutoScrollBehavior),
            new PropertyMetadata(true, OnAllowPauseAutoScrollChanged));

    public static readonly DependencyProperty AutoScrollHorizontalProperty =
            DependencyProperty.RegisterAttached(
            "AutoScrollHorizontal",
            typeof(bool),
            typeof(AutoScrollBehavior),
            new PropertyMetadata(false, OnAutoScrollHorizontalChanged));

    public static readonly DependencyProperty AutoScrollVerticalProperty =
        DependencyProperty.RegisterAttached(
            "AutoScrollVertical",
            typeof(bool),
            typeof(AutoScrollBehavior),
            new PropertyMetadata(true, OnAutoScrollVerticalChanged));

    public static readonly DependencyProperty IsEnabledProperty =
        DependencyProperty.RegisterAttached(
            "IsEnabled",
            typeof(bool),
            typeof(AutoScrollBehavior),
            new PropertyMetadata(false, OnIsEnabledChanged));

    private static readonly DependencyProperty _stateProperty =
        DependencyProperty.RegisterAttached(
            "State",
            typeof(AutoScrollState),
            typeof(AutoScrollBehavior),
            new PropertyMetadata(null));

    #endregion Attached Properties

    #region Accessors

    public static bool GetAllowPauseAutoScroll(DependencyObject obj)
    {
        return (bool)obj.GetValue(AllowPauseAutoScrollProperty);
    }

    public static bool GetAutoScrollHorizontal(DependencyObject obj)
    {
        return (bool)obj.GetValue(AutoScrollHorizontalProperty);
    }

    public static bool GetAutoScrollVertical(DependencyObject obj)
    {
        return (bool)obj.GetValue(AutoScrollVerticalProperty);
    }

    public static bool GetIsEnabled(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsEnabledProperty);
    }

    public static void SetAllowPauseAutoScroll(DependencyObject obj, bool value)
    {
        obj.SetValue(AllowPauseAutoScrollProperty, value);
    }

    public static void SetAutoScrollHorizontal(DependencyObject obj, bool value)
    {
        obj.SetValue(AutoScrollHorizontalProperty, value);
    }

    public static void SetAutoScrollVertical(DependencyObject obj, bool value)
    {
        obj.SetValue(AutoScrollVerticalProperty, value);
    }

    public static void SetIsEnabled(DependencyObject obj, bool value)
    {
        obj.SetValue(IsEnabledProperty, value);
    }

    #endregion Accessors

    #region Property Changed Callbacks

    private static void OnAllowPauseAutoScrollChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var state = GetState(d);
        state?.AllowPauseAutoScroll = (bool)e.NewValue;
    }

    private static void OnAutoScrollHorizontalChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var state = GetState(d);
        state?.AutoScrollHorizontal = (bool)e.NewValue;
    }

    private static void OnAutoScrollVerticalChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var state = GetState(d);
        state?.AutoScrollVertical = (bool)e.NewValue;
    }

    #endregion Property Changed Callbacks

    #region Lifecycle

    private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not FrameworkElement fe)
        {
            return;
        }

        fe.Loaded -= OnLoaded;
        fe.Unloaded -= OnUnloaded;
        CleanupState(fe);

        if ((bool)e.NewValue)
        {
            fe.Loaded += OnLoaded;
            fe.Unloaded += OnUnloaded;
        }
    }

    private static void OnLoaded(object sender, RoutedEventArgs e)
    {
        if (sender is not FrameworkElement fe)
        {
            return;
        }

        CleanupState(fe);

        var state = new AutoScrollState {
            AllowPauseAutoScroll = GetAllowPauseAutoScroll(fe),
            AutoScrollVertical = GetAutoScrollVertical(fe),
            AutoScrollHorizontal = GetAutoScrollHorizontal(fe)
        };

        SetState(fe, state);

        fe.Dispatcher.BeginInvoke(() => {
            if (GetState(fe) != state)
            {
                return;
            }

            var sv = VisualHelper.GetChild<ScrollViewer>(fe);

            if (sv is null)
            {
                return;
            }

            state.ScrollViewer = sv;
            sv.ScrollChanged += state.OnScrollChanged;
            sv.SizeChanged += state.OnSizeChanged;
        }, DispatcherPriority.Loaded);
    }

    private static void OnUnloaded(object sender, RoutedEventArgs e)
    {
        if (sender is FrameworkElement fe)
        {
            CleanupState(fe);
        }
    }

    #endregion Lifecycle

    #region Helpers

    private static void CleanupState(FrameworkElement fe)
    {
        var state = GetState(fe);

        if (state is not null)
        {
            state.Dispose();
            SetState(fe, null);
        }
    }

    private static AutoScrollState? GetState(DependencyObject obj)
    {
        return (AutoScrollState?)obj.GetValue(_stateProperty);
    }

    private static void SetState(DependencyObject obj, AutoScrollState? value)
    {
        obj.SetValue(_stateProperty, value);
    }

    #endregion Helpers
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.