Search code examples
c#wpfdata-bindingcolors

Procedurally generating text with inline <span> XAML from a binding


I am building a C# WPF application using MVVM to decouple the view from the business logic.

One of my data sources is a legacy application that outputs ANSI color codes and I would like to faithfully reproduce these in the UI using textblocks, or boxes, or whatever is most appropriate.

I have been able to write a very simple converter to turn the ANSI codes into elements with styles to set the color:

"\x1b[30mblack\x1b[37mwhite"

becomes

@"<span style=""color:#000000"">black<span style=""color:#BBBBBB"">white</span></span>"

However I can not find a way to bind this text to my views while also reproducing the colors.

Most examples online focus on situations where the text is always going to be the same, so colors can be hardcoded in the XAML and the text is bound to different spans/runs. This is not going to work for me.

I have looked briefly at using the WebBrowser control but this seems like a very big hammer for such a small problem. I'm going to have hundreds of these labels in a list, so performance is a concern.

Finally I found a solution whereby XAML may be written into the view at runtime, but I have had no luck whatsoever getting my converted string to load as valid XAML: Richtextbox wpf binding

This seems to be one of those cases of WPF making a simple problem extremely difficult. Is there a good solution that I am overlooking?


Solution

  • What exactly are you trying to parse here? Your text says you're trying to parse XAML yet the code you've provided is HTML?

    If you can stick with XAML then it's relatively straightforward. First of all you'll need some XAML data in your view model:

    public string[] Spans { get; } = new string[]
    {
        "<Span Foreground=\"Blue\">Hello World!</Span>",
        "<Span Foreground=\"Green\">Goodbye World!</Span>"
    };
    

    Whenever you have a list of things to draw in WPF you usually use an ItemsControl. However, instead of a list you'll probably want a WrapPanel. A converter can be used to convert each element of the list into a span which you can wrap that in a parent ContentControl:

    <ItemsControl ItemsSource="{Binding Spans}">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <ContentControl Content="{Binding Path=., Converter={StaticResource SpanConverter}}" />
            </DataTemplate>
        </ItemsControl.ItemTemplate>
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
    

    Then all you need is the converter itself to take the raw XAML, parse it and return the resulting object. You'll also need to add the default (empty) namespace along with "x" so that the parser know where to find the objects it's deserializing:

    public class SpanConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var context = new ParserContext();
            context.XmlnsDictionary.Add("", "http://schemas.microsoft.com/winfx/2006/xaml/presentation");
            context.XmlnsDictionary.Add("x", "http://schemas.microsoft.com/winfx/2006/xaml");
            return XamlReader.Parse(value?.ToString(), context) as ContentElement;
        }
    
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return Binding.DoNothing;
        }
    }
    

    Result:

    enter image description here

    Now take all this and throw it out, because parsing raw XAML is not how you do this in MVVM. To do this properly you would create a view model for your spans like so:

    public class SpanViewModel
    {       
        public string Text { set; get; }
        public Color Foreground { set; get; }
        // .. plus any other fields you want
    }
    

    And you would create a list of those instead:

    public SpanViewModel[] Spans { get; } = new SpanViewModel[]
    {
        new SpanViewModel{Text="Hello World!", Foreground=Colors.Blue},
        new SpanViewModel{Text="Goodbye World!", Foreground=Colors.Green}
    };
    

    We're going to use a DataTemplate, so get rid of the ItemTemplate from your ItemsControl:

    <ItemsControl ItemsSource="{Binding Spans}">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
    

    And create a DataTemplate for your SpanViewModel, binding to the relevant properties (you'll need to use TextBlock because Span doesn't support binding):

    <Window.Resources>
        <DataTemplate DataType="{x:Type local:SpanViewModel}">
            <TextBlock Text="{Binding Text}">
                <TextBlock.Style>
                    <Style TargetType="{x:Type TextBlock}">
                        <Setter Property="Foreground">
                            <Setter.Value>
                                <SolidColorBrush Color="{Binding Foreground}" />
                            </Setter.Value>
                        </Setter>
                    </Style>
                </TextBlock.Style>
            </TextBlock>
        </DataTemplate>
    </Window.Resources>
    

    There are many variations on this, but this should be enough to get you going.