Slogan / News Ticker / Scrolling Text in WPF (Part 2)

 

This is the follow-up of my last post.

I promised to enhance the scroll behavior. Here it is. You can influence the text in various ways. When the zoom factor turns negative you can even see upside down characters. Also the drift can turn negative; if so the wave moves to the left, but its peaks drift to the right.
Weirdly enough it is a lot of fun playing with the sliders and watching the wave change. Of course, we could still create far more complex movements. This source code here is providing the full basics. The rest is up to you.

What you can influence:

  • text zoom  (negative numbers turn the text upside down)
  • scroll speed
  • wave amplitude
  • wave drift
  • wave length

I had to slightly change the way the vectors are stored to enable zooming. The y-zero-line now is in the middle of the characters and not at the top anymore. Anything above that line is negative, anything below positive. Hence the vectors can be multiplied with a zoom factor without the need to recalculate the position in the WPF control.

<Window x:Class="Ticker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Ticker"
        Height="250"
        Width="500"
        Loaded="Window_Loaded">
  <DockPanel x:Name="MyDockPanel"
             SizeChanged="MyImage_SizeChanged"
             LastChildFill="True">
    <Grid DockPanel.Dock="Top">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
        <ColumnDefinition Width="110"/>
        <ColumnDefinition Width="50*"/>
      </Grid.ColumnDefinitions>
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>

      <Label Content="calculation speed" Grid.Row="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <TextBox Name="Info"
               Grid.Row="0" Grid.Column="1"/>

      <Label Content="zoom" Grid.Row="0" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-1.0" Maximum="1.0" Value="1.0"
              ValueChanged="Slider_Zoom_ValueChanged"
              Grid.Row="0" Grid.Column="3" />

      <Label Content="scroll speed" Grid.Row="1" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.5" Maximum="8.0" Value="2.0"
              ValueChanged="Slider_ScrollSpeed_ValueChanged"
              Grid.Row="1" Grid.Column="1" />

      <Label Content="wave amplitude" Grid.Row="1" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="100.0" Value="50.0"
              ValueChanged="Slider_Amplitude_ValueChanged"
              Grid.Row="1" Grid.Column="3" />

      <Label Content="wave drift" Grid.Row="2" Grid.Column="0"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="-10.0" Maximum="10.0" Value="5.0"
              ValueChanged="Slider_Drift_ValueChanged"
              Grid.Row="2" Grid.Column="1" />

      <Label Content="wave length" Grid.Row="2" Grid.Column="2"
             HorizontalAlignment="Right" Padding="0,0,0,0"/>
      <Slider Minimum="0.0" Maximum="2.0" Value="0.5"
              ValueChanged="Slider_WaveLength_ValueChanged"
              Grid.Row="2" Grid.Column="3" />
    </Grid>
    <Image Name="MyImage"
           DockPanel.Dock="Top"
           Stretch="None"
           Width="{Binding ActualWidth, ElementName=MyDockPanel}"/>
  </DockPanel>
</Window>
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace Ticker {

  public partial class MainWindow : Window {

    private Thread _Thread;
    private TextEngine _TextEngine = new TextEngine();
    private float _WidthInPixels;
    private double _Speed = 1.0; // number of pixels to shift per iteration
    private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
    private BitmapImage _BitmapImage = null;
    private string _ElapsedTime = string.Empty;

    public MainWindow() {
      InitializeComponent();
    } //  constructor

    private void Window_Loaded(object sender, EventArgs e) {
      DataContext = this;

      _Thread = new Thread(Loop);
      _Thread.Name = "MainLoop";
      _Thread.IsBackground = true;
      _Thread.Priority = ThreadPriority.AboveNormal;
      _Thread.Start();

      TimeSpan lInterval = new TimeSpan(0, 0, 0, 0, 50); // run each 50 ms
      EventHandler lHandler = new EventHandler(OnTime);
      DispatcherTimer lDispatcherTimer = new DispatcherTimer(lInterval, DispatcherPriority.Send, lHandler, this.Dispatcher);
    } //

    private void MyImage_SizeChanged(object sender, SizeChangedEventArgs e) {
      DockPanel lDockPanel = sender as DockPanel;
      if (lDockPanel == null) return;
      using (var lGraphics = Graphics.FromHwnd(IntPtr.Zero)) {
        _WidthInPixels = (float)(e.NewSize.Width * lGraphics.DpiX / 96.0);
      }
    } //

    public void OnTime(object XSender, EventArgs e) {
      BitmapImage lBitmapImage = _BitmapImage;
      if (lBitmapImage == null) return;

      MyImage.Source = lBitmapImage;
      Line.adjustDrift();
      Info.Text = _ElapsedTime;
      _AutoResetEvent.Set();
    } //

    private void Loop() {
      float lEnd = 0f;
      int lSectionFrom = 0;
      int lSectionTo = 0;
      Stopwatch lStopwatch = new Stopwatch();

      while (true) {
        _AutoResetEvent.WaitOne();
        lStopwatch.Restart();

        float lWidthInPixel = _WidthInPixels; // copy the value to avoid changes during the calculation
        if (lWidthInPixel <= 0.0) continue;
        List<Line> lSection = _TextEngine.getVectorSection(ref lSectionFrom, ref lSectionTo, lEnd, lWidthInPixel);

        // This value determines the speed.
        // Even numbers give better results due to the rounding error nature of bitmaps.
        // Odd numbers create jitter. Luckily humans have bad eyes, they cannot perceive it.
        lEnd += (float)_Speed;

        if (lSection == null) {
          // end reached, reset text
          lSectionFrom = 0;
          lSectionTo = 0;
          lEnd = 0f;
        }
        else {
          Bitmap lBitmap = _TextEngine.VectorsToBitmap(lSection, lWidthInPixel);
          _BitmapImage = _TextEngine.BitmapToImageSource(lBitmap);
        }

        _ElapsedTime = lStopwatch.ElapsedMilliseconds.ToString("#,##0");
        //lStopwatch.Stop();
      }
    } //

    private void Slider_ScrollSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { _Speed = e.NewValue; }
    private void Slider_Amplitude_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Amplitude = (float)e.NewValue; }
    private void Slider_Drift_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Drift = (float)e.NewValue; }
    private void Slider_WaveLength_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.WaveLength = (float)e.NewValue; }
    private void Slider_Zoom_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { Line.Zoom = (float)e.NewValue; }

  } // class
} // namespace
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;

namespace Ticker {
  public class TextEngine {

    private const float cFontHeight = 40f;
    private const string cText = @"Die Gedanken sind frei, wer kann sie erraten, sie fliegen vorbei wie nächtliche Schatten. Kein Mensch kann sie wissen, kein Jäger erschießen mit Pulver und Blei: Die Gedanken sind frei! Ich denke was ich will und was mich beglücket, doch alles in der Still', und wie es sich schicket. Mein Wunsch und Begehren kann niemand verwehren, es bleibet dabei: Die Gedanken sind frei! Und sperrt man mich ein im finsteren Kerker, das alles sind rein vergebliche Werke. Denn meine Gedanken zerreißen die Schranken und Mauern entzwei: Die Gedanken sind frei! Drum will ich auf immer den Sorgen entsagen und will mich auch nimmer mit Grillen mehr plagen. Man kann ja im Herzen stets lachen und scherzen und denken dabei: Die Gedanken sind frei! Ich liebe den Wein, mein Mädchen vor allen, sie tut mir allein am besten gefallen. Ich sitz nicht alleine bei meinem Glas Weine, mein Mädchen dabei: Die Gedanken sind frei!";
    private List<Line> _TextAsVectorChain = new List<Line>();
    private Font _Font = new Font("Arial", cFontHeight, System.Drawing.FontStyle.Bold, GraphicsUnit.Pixel);

    public TextEngine() {
      // convert the entire text to vectors
      float lPosition = 0;
      Dictionary<char, List<Line>> lVectorCache = new Dictionary<char, List<Line>>();
      char[] lChars = cText.ToCharArray();

      foreach (char lChar in lChars) {
        if (lChar == ' ') lPosition += 10; // distance for an empty space character
        else {
          List<Line> lOneCharVectors;

          if (!lVectorCache.TryGetValue(lChar, out lOneCharVectors)) {
            Bitmap lBitmap = CharToBitmap(lChar);
            lOneCharVectors = BitmapToVectors(lBitmap);
            lVectorCache.Add(lChar, lOneCharVectors);
          }

          float lNewPosition = lPosition;
          foreach (Line lLine in lOneCharVectors) {
            Line lClone = lLine.Clone();
            lClone.X += lPosition;
            lNewPosition = lClone.X;
            _TextAsVectorChain.Add(lClone);
          }
          lPosition = lNewPosition + 4; // 4 == space between two characters
        }

      }
    } // constructor

    // Convert a bitmap to an ImageSource.
    // We can then display the result in the WPF Image element.
    public BitmapImage BitmapToImageSource(Bitmap xBitmap) {
      using (MemoryStream lMemoryStream = new MemoryStream()) {
        xBitmap.Save(lMemoryStream, System.Drawing.Imaging.ImageFormat.Bmp);
        lMemoryStream.Position = 0;
        BitmapImage lBitmapImage = new BitmapImage();
        lBitmapImage.BeginInit();
        lBitmapImage.StreamSource = lMemoryStream;
        lBitmapImage.CacheOption = BitmapCacheOption.OnLoad;
        lBitmapImage.EndInit();
        lBitmapImage.Freeze();
        return lBitmapImage;
      }
    } //

    // draw a single character into a bitmap
    private Bitmap _Bitmap = null;
    public Bitmap CharToBitmap(char xChar) {
      _Bitmap = new Bitmap((int)(cFontHeight * 1.25f), (int)(cFontHeight * 1.25f));
      using (Graphics lGraphics = Graphics.FromImage(_Bitmap)) {
        lGraphics.Clear(Color.White);
        lGraphics.DrawString(xChar.ToString(), _Font, Brushes.Black, 0f, 0f);
      }
      return _Bitmap;
    } //

    // Replicate the characters now by reading the vectors and drawing lines.
    Pen lPen = new Pen(Color.Black, 2f);
    public Bitmap VectorsToBitmap(List<Line> xLines, float xBitmapWidth) {
      int lHeight = (int)cFontHeight + (int)Math.Abs(Line.Amplitude);
      Bitmap lBitmap = new Bitmap((int)xBitmapWidth, lHeight);
      lHeight /= 2;   // half height, rounded down
      using (Graphics lGraphics = Graphics.FromImage(lBitmap)) {
        lGraphics.Clear(Color.White);
        foreach (Line lLine in xLines) {
          lGraphics.DrawLine(lPen, lLine.X, lLine.Y1 + lHeight, lLine.X, lLine.Y2 + lHeight);
        }
      }
      return lBitmap;
    } //

    // Convert a single character to vectors.
    private List<Line> BitmapToVectors(Bitmap xBitmap) {
      int lXCoordinateOfFirstPixel = -1;
      List<Line> lList = new List<Line>();

      for (int x = 0, lWidth = xBitmap.Width; x < lWidth; x++) {
        Line lVector = null;
        for (int y = 0, lHeight = xBitmap.Height; y < lHeight; y++) {
          Color lColor = xBitmap.GetPixel(x, y);
          bool lIsWhite = lColor.B == 255;
          if (lIsWhite) {
            if (lVector != null) {
              lList.Add(lVector);
              lVector = null;
            }
          }
          else {
            int lHalfHeight = xBitmap.Height / 2;
            if (lVector == null) {
              if (lXCoordinateOfFirstPixel < 0) lXCoordinateOfFirstPixel = x;  // to always start at zero for our vectors
              lVector = new Line { X = x - lXCoordinateOfFirstPixel, Y1 = y - lHalfHeight, Y2 = y - lHalfHeight };
            }
            else lVector.Y2 = y - lHalfHeight;
          }
        }
      }

      return lList;
    } //

    // The text was converted to vectors.
    // Now we cut out the sequence we need for the display.
    internal List<Line> getVectorSection(ref int xSectionFrom, ref int xSectionTo, float xEnd, float xWidth) {
      int lCount = _TextAsVectorChain.Count;
      float lStart = xEnd - xWidth;

      // find the right section
      do {
        xSectionTo++;
        if (xSectionTo >= lCount) { xSectionTo = lCount - 1; break; }
        if (xEnd < _TextAsVectorChain[xSectionTo].X) break;
      } while (true);

      do {
        if (lStart < 0) break; // to allow empty spaces at the beginning of the slogan
        if (xSectionFrom >= lCount) return null;
        if (lStart < _TextAsVectorChain[xSectionFrom].X) break;
        xSectionFrom++;
      } while (true);

      // clone that section
      List<Line> lList = new List<Line>();
      for (int x = xSectionFrom; x <= xSectionTo; x++) {
        Line lClone = _TextAsVectorChain[x].Clone();
        lClone.X -= lStart; // adjust the X-axis
        lClone.RecalcYShift();
        lList.Add(lClone);
      }

      return lList;
    } //

  } // class
} // namespace
using System;

namespace Ticker {
  public class Line {
    private float _Y1, _Y1Shift;
    private float _Y2, _Y2Shift;
    private static float _XDrift = 0f;

    public float X { get; set; }
    public static float Amplitude { get; set; }    // This is the additional height needed for the vertical swing
    public static float Drift { get; set; }        // How fast does the wave crest/trough move?
    public static float WaveLength { get; set; }   // How wide is the wave?
    public static float Zoom { get; set; }         // Character zoom factor around the horizontal middle.

    public static void adjustDrift() { _XDrift += Drift; }

    public float Y1 {
      get { return _Y1 * Zoom + _Y1Shift; }
      set { _Y1 = value; }
    } //

    public float Y2 {
      get { return _Y2 * Zoom + _Y2Shift; }
      set { _Y2 = value; }
    } //

    public void RecalcYShift() {
      double d = (double)(WaveLength * (X + _XDrift));
      d = Math.IEEERemainder(d / 50.0, 1.99999999999);
      float lAngle = (float)Math.Sin(Math.PI * d);          // 0.0 <= d < 2.0  therefore -1.0 <= lAngle <= 1.0
      float lShift = Amplitude / 2.0f * lAngle;
      _Y1Shift = lShift;
      _Y2Shift = lShift;
    } //

    public Line Clone() {
      Line lLine = new Line();
      lLine.X = X;
      lLine.Y1 = _Y1;
      lLine.Y2 = _Y2;
      return lLine;
    } //

  } // class
} // namespace

About Bastian M.K. Ohta

Happiness only real when shared.

Posted on April 3, 2015, in Advanced, C#, DataBinding, WPF and tagged , , , , , , , , , , . Bookmark the permalink. 1 Comment.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: