5

I have a WPF UserControl with a BindableRichTextBox:

xmlns:controls="clr-namespace:SysadminsLV.WPF.OfficeTheme.Controls;assembly=Wpf.OfficeTheme"
.
.
.
<controls:BindableRichTextBox Background="Black"
                              Foreground="White"
                              FontFamily="Consolas"
                              FontSize="12"
                              IsReadOnly="True"
                              IsReadOnlyCaretVisible="True"
                              VerticalScrollBarVisibility="Auto"
                              IsUndoEnabled="False"
                              Document="{Binding Contents}"/>

The contents is controlled by a ViewModel property Document:

using System.Windows.Documents;

class MyViewModel : ILogServerContract 
{
    readonly Paragraph _paragraph;

    public MyViewModel() 
    {
        _paragraph = new Paragraph();
        Contents = new FlowDocument(_paragraph);
    }

    public FlowDocument Contents { get; }

    //Log Server Contract Write method (accessed via NetPipe)
    public void WriteLine(string text, int debugLevel) 
    {
        //figure out formatting stuff based on debug level. not important
        _paragraph.Inlines.Add(new Run(text) {
            //set text color
        });
    }
}

As you can see, the RichTextBox Document property is bound to the Contents property from MyViewModel. The Contents property, in turn, is written to via NetPipes by way of the WriteLine() method, which is part of the ILogServerContract interface.

What I'm struggling with is:

  • How to raise an event when the contents of the RichTextBox is updated and then
  • Call the ScrollToEnd() method on the RichTextBox as proposed in this simpler problem. Since the RichTextBox is declared in XAML and not code, I'm not sure how to do that.

Can anyone assist?

BionicCode
  • 1
  • 4
  • 28
  • 44
Mike Bruno
  • 600
  • 2
  • 9
  • 26

1 Answers1

6

You should not implement this kind of view related logic in your view model class. The scroll logic must be part of your control.
Furthermore, Run is a pure view class. It extends FrameworkElement, which should give you a hint to avoid handling this UI element in your view model if possible.

The following snippet scrolls the RichTextBox document to the bottom on text changes:

<RichTextBox TextChanged="OnTextChanged" />
private void OnTextChanged(object sender, TextChangedEventArgs e)
  => this.Dispatcher.InvokeAsync((sender as RichTextBox).ScrollToEnd, DispatcherPriority.Background);

Since you are implementing a simple message view, RichTextBox is not the right control. TextBlock would be more appropriate (it also supports Inline elements like Run to color text).
Now that you want to show multiple lines of text, you should implement your view based on a ListBox that renders its items with the help of a TextBlock.
The main advantage of this approach is the far superior performance. In case of displaying a significant amount of messages, the ListBox provides you with UI virtualization right out of the box - it will always scroll smoothly. The heavy RichTextBox becomes sluggish very quick.

Since your view model must only handle data, first step is to introduce a data model e.g. LogMessageand its related types:

LogMessage.cs

// If you plan to modify existing messages e.g. in order to append text,
// the Message property must have a set() and must raise the PropertyChanged event.
public class LogMessage : INotifyPropertyChanged
{
  public LogMessage(string message, LogLevel logLevel)
  {
    this.Message = message;
    this.LogLevel = logLevel;
  }

  public string Message { get; }
  public LogLevel LogLevel { get; }
  public bool IsNewLine { get; init; }

  public event PropertyChangedEventHandler PropertyChanged;
}

LogLevel.cs

public enum LogLevel
{
  Default = 0,
  Debug,
  Info
}

MainViewModel.cs

class MainViewModel : INotifyPropertyChanged
{
  public ObservableCollection<LogMessage> LogMessages { get; }
  public event PropertyChangedEventHandler PropertyChanged;

  public MainViewModel()
  {
    this.LogMessages = new ObservableCollection<LogMessage>();

    WriteLine("Debug test message.", LogLevel.Debug);
    WriteLine("Info test message.", LogLevel.Info);
  }
 
  // To implement Write() to avoid line breaks, 
  // simply append the new message text to the previous message.
  public void WriteLine(string message, LogLevel logLevel) 
  {
    var newMessage = new LogMessage(message, logLevel) { IsNewLine = true };
    this.LogMessages.Add(newMessage);
  }
}

Then implement the view that displays the messages. Although this example uses a UserControl, I highly recommend to create a custom control by extending Control instead:

LogLevelToBrushConverter.cs

public class LogLevelToBrushConverter : IValueConverter
{
  public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
  {
    return value switch
    {
      LogLevel.Debug => Brushes.Blue,
      LogLevel.Info => Brushes.Gray,
      _ => Brushes.Black
    };
  }

  public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) 
    => throw new NotSupportedException();
}

LogMessageBox.xaml.cs

public partial class LogMessageBox : UserControl
{
  public IList<object> LogMessagesSource
  {
    get => (IList<object>)GetValue(LogMessagesSourceProperty);
    set => SetValue(LogMessagesSourceProperty, value);
  }

  public static readonly DependencyProperty LogMessagesSourceProperty = DependencyProperty.Register(
    "LogMessagesSource", 
    typeof(IList<object>), 
    typeof(LogMessageBox), 
    new PropertyMetadata(default(IList<object>), OnLogMessagesSourceChanged));

  public LogMessageBox()
  {
    InitializeComponent();
  }

  private static void OnLogMessagesSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 
    => (d as LogMessageBox).OnLogMessagesSourceChanged(e.OldValue as IList<object>, e.NewValue as IList<object>);

  // Listen to CollectionChanged events 
  // in order to always keep the last and latest item in view.
  protected virtual void OnLogMessagesSourceChanged(IList<object> oldMessages, IList<object> newMessages)
  {
    if (oldMessages is INotifyCollectionChanged oldObservableCollection)
    {
      oldObservableCollection.CollectionChanged -= OnLogMessageCollectionChanged;
    }
    if (newMessages is INotifyCollectionChanged newObservableCollection)
    {
      newObservableCollection.CollectionChanged += OnLogMessageCollectionChanged;
    }
  }

  private void OnLogMessageCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
  {
    object lastMessageItem = this.LogMessagesSource.LastOrDefault();
    ListBox listBox = this.Output;
    Dispatcher.InvokeAsync(
      () => listBox.ScrollIntoView(lastMessageItem), 
      DispatcherPriority.Background);
  }
}

LogMessageBox.xaml

<UserControl>  
  <ListBox x:Name="Output" 
           ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=LogMessagesSource}">
    <ListBox.ItemContainerStyle>
      <Style TargetType="ListBoxItem">
        <Setter Property="IsHitTestVisible"
                Value="False" />
      </Style>
    </ListBox.ItemContainerStyle>
  </ListBox>
</UserControl>

Usage example

MainWindow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>
    
  <Window.Resources>
    <local:LogLevelToBrushConverter x:Key="LogLevelToBrushConverter" />

    <DataTemplate DataType="{x:Type local:LogMessage}">

      <!-- If you expect Message to change, adjust the Binding.Mode to OneWay. 
           Otherwise leave it as OneTime to improve performance 
      -->
      <TextBlock Text="{Binding Message, Mode=OneTime}"
                 Foreground="{Binding LogLevel, Mode=OneTime, Converter={StaticResource LogLevelToBrushConverter}}" />
    </DataTemplate>
  </Window.Resources>

  <LogMessageBox LogMessagesSource="{Binding LogMessages}" />
</Window>
BionicCode
  • 1
  • 4
  • 28
  • 44
  • 1
    Thanks for all this guidance! I will definitely give this a shot. – Mike Bruno Mar 30 '22 at 19:35
  • 1
    Thanks again for all your help so far. I have everything working except for the LogLevel to Brush conversion. The `Foreground` definition within the `Run` element seems to just be ignored. I must be missing something... – Mike Bruno Mar 31 '22 at 20:37
  • 1
    You should debug the converter. Is it called? – BionicCode Mar 31 '22 at 20:57
  • 1
    You read my mind. Yes it is being called. – Mike Bruno Mar 31 '22 at 20:58
  • 1
    Have you set a LogLevel on the LogMessage items? If not the default is `LogLevel.Default` which will make the converter return black, which will appear like the converter is not working. – BionicCode Mar 31 '22 at 21:04
  • 1
    Yes; I'm definitely setting the severity level. I added some code to my implementation of `IValueConverter.Convert()` to have the `Brush` color names being returned written to a file. It's definitely working as expected. Maybe a XAML binding issue of some sort? – Mike Bruno Mar 31 '22 at 21:10
  • 1
    I have just tested it. There was a typo (I fixed it). Didn't you had compiler errors? (Just to check if you have the latest version). The posted code works for me. Do you get any binding errors? – BionicCode Mar 31 '22 at 21:41
  • 1
    Btw, you can always use `Debug.WriteLine` to output something in the Output window. It's more comfortable than writing to a file. Alternatively introduce a local variable to store the result of the switch to allow to inspect it using the debugger. – BionicCode Mar 31 '22 at 21:47
  • 1
    I mocked up your solution as-is and it works fine; I can't reproduce the problem. I'm thinking my issue is related to log messages being received via NetPipes (coincidentally also the reason I can't test in the VS debugger). You've gotten me 95% of the way there and I really appreciate all the time and effort you put in! – Mike Bruno Apr 01 '22 at 18:26
  • 1
    You are welcome. Too sad you can't reproduce your issue. Note that the current example does not support dynamic values: `LogMessage` exposes read-only properties that therefore don't raise the PropertyChanged event, which would be essential in order to allow dynamic data. Also the data binding to the `LogMessage` is configured as `OneTime`. I'm just saying, in case your issue is related to data being updated dynamically or after the items were added to the source collection. – BionicCode Apr 01 '22 at 20:07
  • 1
    Also ensure that the source collection `LogMessages` is updated from the UI thread! If you return from an async method or background thread you need to marshal the `CollectionChanged` event back to the UI thread so that the data binding engine can handle it. The recommended approach for collections is to use the `BindingOperations` class: 1) create a lock object: `private object SyncLock { get; }` 2) cofigure the `LogMessages` collection in the constructor (UI thread!): `BindingOperations.EnableCollectionSynchronization(this.LogMessages, this.SyncLock);` Hope this helps. – BionicCode Apr 01 '22 at 20:07
  • 3
    Hey Mike, I just realized that you are about to reward me with some reps. Highly appreciated. Thank you very much! Such a kind move!!! Really! – BionicCode Apr 01 '22 at 20:14
  • 1
    Also don't overwrite the `LogMessages` instance. Always use `Clear()`, `Add()` and `Remove)` to modify the collection, just in case you are replacing the collection instance (which currently won't be detected by the data binding). Or, if you insist on replacing the collection instance, let the `LogMessages` property raise the `PropertyChanged` event. But keep in mind that replacing the collection has some serious impact on the UI performance as it will trigger the ListBox (or ItemsControl in general) to completely recalculate it's layout and generate new item containers, so better don't do it. – BionicCode Apr 01 '22 at 20:20
  • 1
    If you need more help you should post the current context of your version so that we can review how you were updating the source collection. The NetPipes library or service itself is very likely not the reason. – BionicCode Apr 01 '22 at 20:24