Create guitar chords editor in WPF (from RichTextBox?) Create guitar chords editor in WPF (from RichTextBox?) wpf wpf

Create guitar chords editor in WPF (from RichTextBox?)


I cannot give you any concrete help but in terms of architecture you need to change your layout from this

lines suck

To this

glyphs rule

Everything else is a hack. Your unit/glyph must become a word-chord-pair.


Edit: I have been fooling around with a templated ItemsControl and it even works out to some degree, so it might be of interest.

<ItemsControl Grid.IsSharedSizeScope="True" ItemsSource="{Binding SheetData}"              Name="_chordEditor">    <ItemsControl.ItemsPanel>        <ItemsPanelTemplate>            <WrapPanel/>        </ItemsPanelTemplate>    </ItemsControl.ItemsPanel>    <ItemsControl.ItemTemplate>        <DataTemplate>            <Grid>                <Grid.RowDefinitions>                    <RowDefinition SharedSizeGroup="A" Height="Auto"/>                    <RowDefinition SharedSizeGroup="B" Height="Auto"/>                </Grid.RowDefinitions>                <Grid.Children>                    <TextBox Name="chordTB" Grid.Row="0" Text="{Binding Chord}"/>                    <TextBox Name="wordTB"  Grid.Row="1" Text="{Binding Word}"                             PreviewKeyDown="Glyph_Word_KeyDown" TextChanged="Glyph_Word_TextChanged"/>                </Grid.Children>            </Grid>        </DataTemplate>    </ItemsControl.ItemTemplate></ItemsControl>
private readonly ObservableCollection<ChordWordPair> _sheetData = new ObservableCollection<ChordWordPair>();public ObservableCollection<ChordWordPair> SheetData{    get { return _sheetData; }}
public class ChordWordPair: INotifyPropertyChanged{    private string _chord = String.Empty;    public string Chord    {        get { return _chord; }        set        {            if (_chord != value)            {                _chord = value;                // This uses some reflection extension method,                // a normal event raising method would do just fine.                PropertyChanged.Notify(() => this.Chord);            }        }    }    private string _word = String.Empty;    public string Word    {        get { return _word; }        set        {            if (_word != value)            {                _word = value;                PropertyChanged.Notify(() => this.Word);            }        }    }    public ChordWordPair() { }    public ChordWordPair(string word, string chord)    {        Word = word;        Chord = chord;    }    public event PropertyChangedEventHandler PropertyChanged;}
private void AddNewGlyph(string text, int index){    var glyph = new ChordWordPair(text, String.Empty);    SheetData.Insert(index, glyph);    FocusGlyphTextBox(glyph, false);}private void FocusGlyphTextBox(ChordWordPair glyph, bool moveCaretToEnd){    var cp = _chordEditor.ItemContainerGenerator.ContainerFromItem(glyph) as ContentPresenter;    Action focusAction = () =>    {        var grid = VisualTreeHelper.GetChild(cp, 0) as Grid;        var wordTB = grid.Children[1] as TextBox;        Keyboard.Focus(wordTB);        if (moveCaretToEnd)        {            wordTB.CaretIndex = int.MaxValue;        }    };    if (!cp.IsLoaded)    {        cp.Loaded += (s, e) => focusAction.Invoke();    }    else    {        focusAction.Invoke();    }}private void Glyph_Word_TextChanged(object sender, TextChangedEventArgs e){    var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;    var tb = sender as TextBox;    string[] glyphs = tb.Text.Split(' ');    if (glyphs.Length > 1)    {        glyph.Word = glyphs[0];        for (int i = 1; i < glyphs.Length; i++)        {            AddNewGlyph(glyphs[i], SheetData.IndexOf(glyph) + i);        }    }}private void Glyph_Word_KeyDown(object sender, KeyEventArgs e){    var tb = sender as TextBox;    var glyph = (sender as FrameworkElement).DataContext as ChordWordPair;    if (e.Key == Key.Left && tb.CaretIndex == 0 || e.Key == Key.Back && tb.Text == String.Empty)    {        int i = SheetData.IndexOf(glyph);        if (i > 0)        {            var leftGlyph = SheetData[i - 1];            FocusGlyphTextBox(leftGlyph, true);            e.Handled = true;            if (e.Key == Key.Back) SheetData.Remove(glyph);        }    }    if (e.Key == Key.Right && tb.CaretIndex == tb.Text.Length)    {        int i = SheetData.IndexOf(glyph);        if (i < SheetData.Count - 1)        {            var rightGlyph = SheetData[i + 1];            FocusGlyphTextBox(rightGlyph, false);            e.Handled = true;        }    }}

Initially some glyph should be added to the collection, otherwise there will be no input field (this can be avoided with further templating, e.g. by using a datatrigger that shows a field if the collection is empty).

Perfecting this would require a lot of additional work like styling the TextBoxes, adding written line breaks (right now it only breaks when the wrap panel makes it), supporting selection accross multiple textboxes, etc.


Soooo, I had a little fun here. This is how it looks like:

capture

The lyrics is fully editable, the chords are currently not (but this would be an easy extension).

this is the xaml:

<Window ...>    <AdornerDecorator>        <!-- setting the LineHeight enables us to position the Adorner on top of the text -->        <RichTextBox TextBlock.LineHeight="25" Padding="0,25,0,0" Name="RTB"/>    </AdornerDecorator>    </Window>

and this is the code:

public partial class MainWindow{    public MainWindow()    {        InitializeComponent();        const string input = "E                 E6\nI know I stand in line until you\nE                  E6               F#m            B F#m B\nthink you have the time to spend an evening with me                ";        var lines = input.Split('\n');        var paragraph = new Paragraph{Margin = new Thickness(0),Padding = new Thickness(0)}; // Paragraph sets default margins, don't want those        RTB.Document = new FlowDocument(paragraph);        // this is getting the AdornerLayer, we explicitly included in the xaml.        // in it's visual tree the RTB actually has an AdornerLayer, that would rather        // be the AdornerLayer we want to get        // for that you will either want to subclass RichTextBox to expose the Child of        // GetTemplateChild("ContentElement") (which supposedly is the ScrollViewer        // that hosts the FlowDocument as of http://msdn.microsoft.com/en-us/library/ff457769(v=vs.95).aspx         // , I hope this holds true for WPF as well, I rather remember this being something        // called "PART_ScrollSomething", but I'm sure you will find that out)        //        // another option would be to not subclass from RTB and just traverse the VisualTree        // with the VisualTreeHelper to find the UIElement that you can use for GetAdornerLayer        var adornerLayer = AdornerLayer.GetAdornerLayer(RTB);        for (var i = 1; i < lines.Length; i += 2)        {            var run = new Run(lines[i]);            paragraph.Inlines.Add(run);            paragraph.Inlines.Add(new LineBreak());            var chordpos = lines[i - 1].Split(' ');            var pos = 0;            foreach (string t in chordpos)            {                if (!string.IsNullOrEmpty(t))                {                    var position = run.ContentStart.GetPositionAtOffset(pos);                    adornerLayer.Add(new ChordAdorner(RTB,t,position));                }                pos += t.Length + 1;            }        }    }}

using this Adorner:

public class ChordAdorner : Adorner{    private readonly TextPointer _position;    private static readonly PropertyInfo TextViewProperty = typeof(TextSelection).GetProperty("TextView", BindingFlags.Instance | BindingFlags.NonPublic);    private static readonly EventInfo TextViewUpdateEvent = TextViewProperty.PropertyType.GetEvent("Updated");    private readonly FormattedText _formattedText;    public ChordAdorner(RichTextBox adornedElement, string chord, TextPointer position) : base(adornedElement)    {        _position = position;        // I'm in no way associated with the font used, nor recommend it, it's just the first example I found of FormattedText        _formattedText = new FormattedText(chord, CultureInfo.GetCultureInfo("en-us"),FlowDirection.LeftToRight,new Typeface(new FontFamily("Arial").ToString()),12,Brushes.Black);        // this is where the magic starts        // you would otherwise not know when to actually reposition the drawn Chords        // you could otherwise only subscribe to TextChanged and schedule a Dispatcher        // call to update this Adorner, which either fires too often or not often enough        // that's why you're using the RichTextBox.Selection.TextView.Updated event        // (you're then basically updating the same time that the Caret-Adorner        // updates it's position)        Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>        {            object textView = TextViewProperty.GetValue(adornedElement.Selection, null);            TextViewUpdateEvent.AddEventHandler(textView, Delegate.CreateDelegate(TextViewUpdateEvent.EventHandlerType, ((Action<object, EventArgs>)TextViewUpdated).Target, ((Action<object, EventArgs>)TextViewUpdated).Method));            InvalidateVisual(); //call here an event that triggers the update, if                                 //you later decide you want to include a whole VisualTree                                //you will have to change this as well as this ----------.        }));                                                                          // |    }                                                                                 // |                                                                                      // |    public void TextViewUpdated(object sender, EventArgs e)                           // |    {                                                                                 // V        Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(InvalidateVisual));    }    protected override void OnRender(DrawingContext drawingContext)    {        if(!_position.HasValidLayout) return; // with the current setup this *should* always be true. check anyway        var pos = _position.GetCharacterRect(LogicalDirection.Forward).TopLeft;        pos += new Vector(0, -10); //reposition so it's on top of the line        drawingContext.DrawText(_formattedText,pos);    }}

this is using an adorner like david suggested, but I know it is hard to find a how to out there. That's probably because there is none. I had spent hours before in reflector trying to find that exact event that signals that the layout of the flowdocument has been figured out.

I'm not sure if that dispatcher call in the constructor is actually needed, but I left it in for being bulletproof. (I needed this because in my setup the RichTextBox had not been shown yet).

Obviously this needs a lot more coding, but this will give you a start. You will want to play around with positioning and such.

For getting the positioning right if two adorners are too close and are overlapping I'd suggest you somehow keep track of which adorner comes before and see if the current one would overlap. then you can for example iteratively insert a space before the _position-TextPointer.

If you later decide, you want the chords editable too, you can instead of just drawing the text in OnRender have a whole VisualTree under the adorner. (here is an example of an adorner with a ContentControl underneath). Beware though that you have to handle the ArrangeOveride then to correctly position the Adorner by the _position CharacterRect.