Implementing a log viewer with WPF
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.
I should start selling these WPF samples instead of giving them out for free. =P
- Virtualized UI (Using
VirtualizingStackPanel
) which provides incredibly good performance (even with 200000+ items) - Fully MVVM-friendly.
-
DataTemplate
s for each kind ofLogEntry
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 subclassLogEntry
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));
}));
}
}
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);
}
}
}