Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Create guitar chords editor in WPF (from RichTextBox?)

Main purpose of application I'm working on in WPF is to allow editing and consequently printing of songs lyrics with guitar chords over it.

You have probably seen chords even if you don't play any instrument. To give you an idea it looks like this:

E                 E6
I know I stand in line until you
E                  E6               F#m            B F#m B
think you have the time to spend an evening with me

But instead of this ugly mono-spaced font I want to have Times New Roman font with kerning for both lyrics and chords (chords in bold font). And I want user to be able to edit this.

This does not appear to be supported scenario for RichTextBox. These are some of the problems that I don't know how to solve:

  • Chords have their positions fixed over some character in lyrics text (or more generally TextPointer of lyrics line). When user edits lyrics I want chord to stay over right character. Example:

.

E                                       E6
I know !!!SOME TEXT REPLACED HERE!!! in line until you
  • Line wrapping: 2 lines (1th with chords and 2th with lyrics) are logically one line when it comes to wrapping. When a word wraps to next line all chords that are over it should also wrap. Also when chord wraps the word that it is over it also wrap. Example:

.

E                  E6
think you have the time to spend an
F#m            B F#m B
evening with me
  • Chords should stay over right character even when chords are too near to each other. In this case some extra space is automatically inserted in lyrics line. Example:

.

                  F#m E6
  ...you have the ti  me to spend... 
  • Say I have lyrics line Ta VA and chord over A. I want the lyrics to look like kering right not like enter image description here. Second picture is not kerned between V and A. Orange lines are there only to visualize the effect (but they mark x offsets where chord would be placed). Code used to produce first sample is <TextBlock FontFamily="Times New Roman" FontSize="60">Ta VA</TextBlock> and for second sample <TextBlock FontFamily="Times New Roman" FontSize="60"><Span>Ta V<Floater />A</Span></TextBlock>.

Any ideas on how to get RichTextBox to do this ? Or is there better way to do it in WPF? Will I sub-classing Inline or Run help? Any ideas, hacks, TextPointer magic, code or links to related topics are welcome.


Edit:

I'm exploring 2 major directions to solve this problem but both lead to another problems so I ask new question:

  1. Trying to turn RichTextBox into chords editor - Have a look at How can I create subclass of class Inline?.
  2. Build new editor from separate components like Panels TextBoxes etc. as suggested in H.B. answer. This would need a lot of coding and also led to following (unsolved) problems:

    • Components will change their Width/Height according to they layout position (white space removal at line beginning etc.)
    • Kerning will have to be inserted manually at components boundaries.
    • How to make RichTextBox look like TextBlock? (not elegant hack/workaround is known)

Edit#2

Markus Hütter's high quality answer has shown me that a lot more can be done with RichTextBox then I expected when I was trying to tweak it for my needs myself. I've had time to explore the answer in details only now. Markus might be RichTextBox magician I need to help me with this but there are some unsolved problems with his solution as well:

  1. This application will be all about "beautifully" printed lyrics. The main goal is that the text looks perfect from the typographic point of view. When chords are too near to each other or even overlapping Markus suggests that I iteratively add addition spaces before its position until their distance is sufficient. There is actually requirement that the user can set minimum distance between 2 chords. That minimum distance should be honored and not exceeded until necessary. Spaces are not granular enough - once I add last space needed I'll probably make the gap wider then necessary - that will make the document look 'bad' I don't think it could be accepted. I'd need to insert space of custom width.
  2. There could be lines with no chords (only text) or even lines with no text (only chords). When LineHeight is set to 25 or other fixed value for whole document it will cause lines with no chords to have "empty lines" above them. When there are only chords and no text there will be no space for them.

There are other minor problems but I either think I can solve them or I consider them not important. Anyway I think Markus's answer is really valuable - not only for showing me possible way to go but also as a demonstration of general pattern of using RichTextBox with adorner.

like image 307
Rasto Avatar asked Apr 26 '11 21:04

Rasto


1 Answers

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.

like image 177
H.B. Avatar answered Sep 27 '22 19:09

H.B.