Подтвердить что ты не робот

Создать редактор гитарных аккордов в WPF (из RichTextBox?)

Основная цель приложения, над которым я работаю в WPF, - разрешить редактирование и, следовательно, печать текстов песен с гитарными аккордами над ним.

Возможно, вы видели аккорды, даже если вы не играете на каком-либо инструменте. Чтобы дать вам представление, это выглядит так:

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

Но вместо этого уродливого шрифта с одним шрифтом я хочу иметь шрифт Times New Roman с кернинг как для лирики, так и для аккордов (аккорды жирным шрифтом). И я хочу, чтобы пользователь мог редактировать это.

Этот сценарий не поддерживается для RichTextBox. Вот некоторые из проблем, которые я не знаю, как решить:

  • Аккорды имеют свои позиции, закрепленные над каким-либо персонажем в тексте текста (или, в общем, TextPointer строки текста). Когда пользователь редактирует текст, я хочу, чтобы аккорд оставался верным персонажем. Пример:

.

E                                       E6
I know !!!SOME TEXT REPLACED HERE!!! in line until you
  • Линейная упаковка: 2 строки (1-я с аккордами и 2-я лирика) логически одна линия, когда дело доходит до упаковки. Когда слово переносится на следующую строку, все хорды, которые над ним, должны также завернуться. Также, когда аккорд обертывает слово, что он над ним, он также обертывается. Пример:

.

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 и аккорд над A. Я хочу, чтобы текст песни выглядел как kering right не как enter image description here. Второе изображение не выражается между V и A. Оранжевые линии есть только для визуализации эффекта (но они отмечают смещения x, где размещается аккорд). Код, используемый для создания первого образца, - <TextBlock FontFamily="Times New Roman" FontSize="60">Ta VA</TextBlock> и для второго образца <TextBlock FontFamily="Times New Roman" FontSize="60"><Span>Ta V<Floater />A</Span></TextBlock>.

Любые идеи о том, как получить RichTextBox для этого? Или есть лучший способ сделать это в WPF? Будет ли я подклассифицировать Inline или Run справку? Любые идеи, хаки, магия TextPointer, код или ссылки на связанные темы приветствуются.


Изменить:

Я изучаю 2 основных направления для решения этой проблемы, но оба они приводят к другим проблемам, поэтому я задаю новый вопрос:


Edit # 2

Высокий качественный ответ Markus Hütter показал мне, что гораздо больше можно сделать с помощью RichTextBox, тогда я ожидал, когда я сам попытаюсь настроить его для себя, Я успел подробно изучить ответ только сейчас. Маркус может быть магом RichTextBox Мне нужно помочь мне в этом, но есть и нерешенные проблемы с его решением:

  • Это приложение будет посвящено "красивой" печатной лирике. Основная цель заключается в том, что текст выглядит идеально с типографической точки зрения. Когда аккорды слишком близки друг к другу или даже перекрываются, Маркус предполагает, что я итеративно добавляю дополнительные пространства до его положения до тех пор, пока их расстояние не станет достаточным. Существует фактически требование, чтобы пользователь мог установить минимальное расстояние между двумя аккордами. Это минимальное расстояние должно быть соблюдено и не превышено до тех пор, пока это не будет необходимо. Пробелы не достаточно гранулированы - как только я добавлю последнее пространство, я, вероятно, сделаю этот пробел более широким, чем необходимо, - что сделает документ "плохим". Я не думаю, что его можно было бы принять. Мне нужно будет вставить пространство пользовательской ширины.
  • Могут быть строки без аккордов (только текст) или даже строки без текста (только аккорды). Если для параметра LineHeight установлено значение 25 или другое фиксированное значение для всего документа, это приведет к тому, что строки без аккордов будут иметь "пустые строки" над ними. Когда есть только аккорды, и нет текста, для них не будет места.

Существуют и другие незначительные проблемы, но я либо думаю, что смогу их решить, либо считаю их неважными. В любом случае, я думаю, что ответ Маркуса действительно ценен - ​​не только для того, чтобы показать мне возможный путь, но и как демонстрацию общей картины использования RichTextBox с adorner.

4b9b3361

Ответ 1

Я не могу дать вам конкретной помощи, но с точки зрения архитектуры вам нужно изменить макет из этого

lines suck

Для этого

glyphs rule

Все остальное - это взломать. Ваш unit/glyph должен стать парой слов-аккордов.


Изменить: Я обманывал шаблоном ItemsControl, и он даже работает в некоторой степени, поэтому он может представлять интерес.

<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;
        }
    }
}

Изначально в коллекцию должен быть добавлен некоторый глиф, иначе не будет поля ввода (этого можно избежать при дальнейшем шаблонизации, например, с помощью datatrigger, который показывает поле, если коллекция пуста).

Для этого потребуется много дополнительной работы, например, стилизация TextBoxes, добавление разрывов строк (теперь это только ломается, когда делает панель обертки), поддерживая выбор по нескольким текстовым полям и т.д.

Ответ 2

Соооо, здесь мне было очень весело. Вот как это выглядит:

capture

Тексты полностью редактируются, аккорды в настоящее время не являются (но это было бы легким расширением).

это 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>

и это код:

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 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;
            }
        }

    }
}

используя этого 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 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 why you're using the RichTextBox.Selection.TextView.Updated event
        // (you're then basically updating the same time that the Caret-Adorner
        // updates it 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 on top of the line
        drawingContext.DrawText(_formattedText,pos);
    }
}

это использует рекламодателя, как предложил Дэвид, но я знаю, что трудно найти, как там. Это возможно потому, что их нет. Я провел несколько часов до этого в рефлекторе, пытаясь найти то точное событие, которое сигнализирует о том, что макет документа flowdocument был выяснен.

Я не уверен, действительно ли нужен диспетчер в конструкторе, но я оставил его для того, чтобы быть пуленепробиваемым. (Мне это нужно, потому что в моей настройке RichTextBox еще не был показан).

Очевидно, для этого требуется гораздо больше кодирования, но это даст вам начало. Вы захотите поиграть с позиционированием и т.д.

Для правильного позиционирования, если два adorners слишком близки и перекрываются, я бы посоветовал вам как-то следить за тем, какой adorner приходит раньше, и посмотреть, будет ли текущий перекрываться. то вы можете, например, итеративно вставить пробел перед _position -TextPointer.

Если позже вы решите, что хорды тоже редактируются, вы можете вместо того, чтобы просто рисовать текст в OnRender, есть целый VisualTree под adorner. (здесь является примером adorner с ContentControl под ним). Остерегайтесь, хотя вам придется обрабатывать ArrangeOveride, чтобы правильно расположить Adorner с помощью _position CharacterRect.