Создать редактор гитарных аккордов в 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, Я хочу, чтобы текст был похож керинг право не как , Вторая картинка не вставлена ​​между 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 основных направления для решения этой проблемы, но оба приводят к другим проблемам, поэтому я задаю новый вопрос:

  1. Пытаясь повернуть RichTextBox в редактор аккордов - посмотрите, как я могу создать подкласс класса Inline?,
  2. Создать новый редактор из отдельных компонентов, таких как Panels TextBoxи т. д. как предложено в ответе HB. Это потребовало бы много кодирования, а также привело к следующим (нерешенным) проблемам:


Изменить #2

Высококачественный ответ Маркуса Хюттера показал мне, что можно сделать гораздо больше RichTextBox потом я ожидал, когда попытался сам подстроить его под свои нужды. У меня было время, чтобы изучить ответ в деталях только сейчас. Маркус может быть RichTextBox волшебник, мне нужно помочь мне с этим, но есть некоторые нерешенные проблемы с его решением:

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

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

2 ответа

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

линии сосут

К этому

правило глифов

Все остальное взломать. Ваш юнит / глиф должен стать парой слов-аккордов.


Изменить: я дурачился с шаблонным 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;
        }
    }
}

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

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

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

захватить

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

это 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'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;
            }
        }

    }
}

используя этот 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);
    }
}

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

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

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

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

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

Другие вопросы по тегам