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:
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
.
E E6
think you have the time to spend an
F#m B F#m B
evening with me
.
F#m E6
...you have the ti me to spend...
Ta VA
and chord over A
. I want the lyrics to look like not like . 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.
I'm exploring 2 major directions to solve this problem but both lead to another problems so I ask new question:
RichTextBox
into chords editor - Have a look at How can I create subclass of class Inline?.Build new editor from separate components like Panel
s TextBox
es etc. as suggested in H.B. answer. This would need a lot of coding and also led to following (unsolved) problems:
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:
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.
I cannot give you any concrete help but in terms of architecture you need to change your layout from this
To this
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With